Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
modelcatalog/data/models.json linguist-generated=true -diff
web/** linguist-vendored=true
web/dist/** linguist-generated=true
8 changes: 8 additions & 0 deletions agent/llmagent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ func New(cfg Config) (agent.Agent, error) {
func (a *llmAgent) Name() string { return a.name }
func (a *llmAgent) Description() string { return a.description }

// ModelName returns the configured model ID when the model exposes one.
func (a *llmAgent) ModelName() string {
if named, ok := a.model.(interface{ ModelName() string }); ok {
return named.ModelName()
}
return ""
}

// Run yields model and tool events until the model finishes or the call limit is reached.
func (a *llmAgent) Run(ctx context.Context, invocation *agent.InvocationContext) iter.Seq2[*session.Event, error] {
return func(yield func(*session.Event, error) bool) {
Expand Down
41 changes: 41 additions & 0 deletions cmd/launcher/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Package launcher defines shared configuration for local Agent launchers.
package launcher

import (
"fmt"
"strings"

"github.com/sylumi/agentkit/agent"
"github.com/sylumi/agentkit/session"
)

// Config supplies an Agent and optional services to console and web launchers.
// Callers retain ownership of supplied services; launchers do not close them.
type Config struct {
Agent agent.Agent
SessionService session.Service // Defaults to a new in-memory service.
AppName string // Defaults to Agent.Name().
UserID string // Defaults to "user".
}

// Resolve validates Config and returns a copy with omitted defaults filled in.
// Supplied services are reused. Resolve once and share the result to reuse an
// automatically created session service across multiple launchers.
func (c Config) Resolve() (Config, error) {
if c.Agent == nil || strings.TrimSpace(c.Agent.Name()) == "" {
return Config{}, fmt.Errorf("launcher: a named Agent is required")
}
if c.AppName == "" {
c.AppName = c.Agent.Name()
}
if c.UserID == "" {
c.UserID = "user"
}
if strings.TrimSpace(c.AppName) == "" || strings.TrimSpace(c.UserID) == "" {
return Config{}, fmt.Errorf("launcher: app name and user ID must not be blank")
}
if c.SessionService == nil {
c.SessionService = session.InMemoryService()
}
return c, nil
}
115 changes: 115 additions & 0 deletions cmd/launcher/console/console.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Package console runs an Agent as an interactive terminal conversation.
package console

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strings"
"syscall"

"github.com/sylumi/agentkit/cmd/launcher"
"github.com/sylumi/agentkit/model"
"github.com/sylumi/agentkit/runner"
"github.com/sylumi/agentkit/session"
)

// Run chats over stdin/stdout using the configured Agent and session service.
// Ctrl+C, SIGTERM, or context cancellation ends the conversation normally.
func Run(ctx context.Context, cfg launcher.Config) error {
if ctx == nil {
return fmt.Errorf("console: context is required")
}
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
err := run(ctx, cfg, os.Stdin, os.Stdout)
if errors.Is(err, context.Canceled) && ctx.Err() != nil {
return nil
}
return err
}

func run(ctx context.Context, cfg launcher.Config, input io.Reader, output io.Writer) error {
cfg, err := cfg.Resolve()
if err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()

// Runner saves user, assistant, and tool events in this shared session.
created, err := cfg.SessionService.Create(ctx, &session.CreateRequest{AppName: cfg.AppName, UserID: cfg.UserID})
if err != nil {
return err
}
r, err := runner.New(runner.Config{AppName: cfg.AppName, Agent: cfg.Agent, SessionService: cfg.SessionService})
if err != nil {
return err
}
sessionID := created.Session.ID()
scanner := bufio.NewScanner(input)
lines := make(chan string)
go func() {
defer close(lines)
for scanner.Scan() {
select {
case lines <- scanner.Text():
case <-ctx.Done():
return
}
}
}()
fmt.Fprintln(output, "Chat with the assistant. Press Ctrl+C or Ctrl+D to exit.")
for {
fmt.Fprint(output, "\nYou: ")
select {
case <-ctx.Done():
return ctx.Err()
case line, ok := <-lines:
if !ok {
return scanner.Err()
}
prompt := strings.TrimSpace(line)
if prompt == "" {
continue
}
if err := runTurn(ctx, r, cfg, sessionID, prompt, output); err != nil {
return err
}
}
}
}

func runTurn(ctx context.Context, r *runner.Runner, cfg launcher.Config, sessionID, prompt string, output io.Writer) error {
input := &model.Message{Role: model.RoleUser, Parts: []model.Part{model.NewTextPart(prompt)}}
for event, err := range r.Run(ctx, cfg.UserID, sessionID, input) {
if err != nil {
return err
}
if event.Message != nil {
for _, part := range event.Message.Parts {
switch part.Kind {
case model.PartThinking:
fmt.Fprintf(output, "Thinking (%s): %s\n", part.Thinking.Kind, part.Thinking.Text)
case model.PartText:
fmt.Fprintf(output, "Assistant: %s\n", *part.Text)
case model.PartToolCall:
fmt.Fprintf(output, "Tool call: %s(%s)\n", part.ToolCall.Name, part.ToolCall.Arguments)
case model.PartToolResult:
fmt.Fprintf(output, "Tool result (error=%t): %s\n", part.ToolResult.IsError, part.ToolResult.Content)
}
}
}
if event.StopReason != "" && event.StopReason != model.StopReasonStop && event.StopReason != model.StopReasonToolCalls {
fmt.Fprintf(output, "\n[Notice: Generation ended with %s]\n", event.StopReason)
}
Comment thread
ktsoator marked this conversation as resolved.
}
return nil
}
88 changes: 88 additions & 0 deletions cmd/launcher/console/console_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package console

import (
"bytes"
"context"
"errors"
"io"
"iter"
"strings"
"testing"
"time"

"github.com/sylumi/agentkit/agent"
"github.com/sylumi/agentkit/cmd/launcher"
"github.com/sylumi/agentkit/model"
"github.com/sylumi/agentkit/session"
)

type testAgent struct {
run func(context.Context, *agent.InvocationContext) iter.Seq2[*session.Event, error]
}

func (testAgent) Name() string { return "chat" }
func (testAgent) Description() string { return "Test chat" }
func (a testAgent) Run(ctx context.Context, inv *agent.InvocationContext) iter.Seq2[*session.Event, error] {
return a.run(ctx, inv)
}

func TestConsoleKeepsHistoryAndCancelsWhileWaitingForInput(t *testing.T) {
var turns []int
a := testAgent{run: func(ctx context.Context, inv *agent.InvocationContext) iter.Seq2[*session.Event, error] {
return func(yield func(*session.Event, error) bool) {
turns = append(turns, inv.Session.Events().Len())
e := session.NewEvent(inv.InvocationID)
e.Message = &model.Message{Role: model.RoleAssistant, Parts: []model.Part{model.NewTextPart("Hello")}}
yield(e, nil)
}
}}
store := session.InMemoryService()
cfg := launcher.Config{Agent: a, SessionService: store, AppName: "custom-app", UserID: "alice"}
var output bytes.Buffer
if err := run(t.Context(), cfg, strings.NewReader("hi\n\nagain\n"), &output); err != nil {
t.Fatal(err)
}
if len(turns) != 2 || turns[0] != 1 || turns[1] != 3 || strings.Count(output.String(), "Assistant: Hello") != 2 {
t.Fatal("conversation history was lost", turns, output.String())
}
listed, err := store.List(t.Context(), &session.ListRequest{AppName: cfg.AppName, UserID: cfg.UserID})
if err != nil || len(listed.Sessions) != 1 {
t.Fatalf("configured service was not used: %v", err)
}
loaded, err := store.Get(t.Context(), &session.GetRequest{AppName: cfg.AppName, UserID: cfg.UserID, SessionID: listed.Sessions[0].ID()})
if err != nil || loaded.Session.Events().Len() != 4 {
t.Fatalf("configured service did not retain the conversation: %v", err)
}
input, writer := io.Pipe()
defer input.Close()
defer writer.Close()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
finished := make(chan error, 1)
go func() { finished <- run(ctx, launcher.Config{Agent: a}, input, io.Discard) }()
cancel()
select {
case err := <-finished:
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("cancellation waited for terminal input")
}
}

func TestConsoleCancelsActiveRun(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
a := testAgent{run: func(ctx context.Context, inv *agent.InvocationContext) iter.Seq2[*session.Event, error] {
return func(yield func(*session.Event, error) bool) {
cancel()
<-ctx.Done()
yield(nil, ctx.Err())
}
}}
err := run(ctx, launcher.Config{Agent: a}, strings.NewReader("wait\n"), io.Discard)
if !errors.Is(err, context.Canceled) {
t.Fatalf("turn did not use its caller's cancellation: %v", err)
}
}
63 changes: 63 additions & 0 deletions cmd/launcher/full/full.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Package full combines console and web launchers with the bundled UI.
package full

import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"

"github.com/sylumi/agentkit/cmd/launcher"
"github.com/sylumi/agentkit/cmd/launcher/console"
weblauncher "github.com/sylumi/agentkit/cmd/launcher/web"
"github.com/sylumi/agentkit/web"
)

// Run starts a console with no arguments, or the web UI with ["web"].
// Web accepts -addr (default 127.0.0.1:8080). Both modes use Config's services
// and defaults. Use the console or web package directly for a narrower build.
// Interrupts and context cancellation stop the launcher normally. Model
// settings and credentials belong to the caller's Agent configuration.
func Run(ctx context.Context, cfg launcher.Config, args []string) error {
mode, addr, err := parseArgs(args, os.Stderr)
if errors.Is(err, flag.ErrHelp) {
return nil
}
if err != nil {
return err
}
if ctx == nil {
return fmt.Errorf("launcher: context is required")
}
if mode == "web" {
return weblauncher.Run(ctx, cfg, addr, web.Handler())
}
return console.Run(ctx, cfg)
}

func parseArgs(args []string, output io.Writer) (mode, addr string, err error) {
if len(args) == 0 {
return "console", "", nil
}
switch args[0] {
case "-h", "--help", "help":
fmt.Fprintln(output, "Usage: <program> [web [-addr 127.0.0.1:8080]]\n\nNo arguments: interactive chat.\nweb: serve the built-in UI and API together.")
return "", "", flag.ErrHelp
case "web":
flags := flag.NewFlagSet("web", flag.ContinueOnError)
flags.SetOutput(output)
flags.StringVar(&addr, "addr", "127.0.0.1:8080", "HTTP listen address")
if err := flags.Parse(args[1:]); err != nil {
return "", "", err
}
if flags.NArg() != 0 || strings.TrimSpace(addr) == "" {
return "", "", fmt.Errorf("launcher: expected web [-addr host:port]")
}
return "web", addr, nil
default:
return "", "", fmt.Errorf("launcher: unknown command %q; use web or run without arguments for chat", args[0])
}
}
69 changes: 69 additions & 0 deletions cmd/launcher/web/http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package web

import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)

func listenAndServe(ctx context.Context, addr string, handler http.Handler) error {
if ctx == nil {
return fmt.Errorf("web: context is required")
}
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
if err := ctx.Err(); err != nil {
if errors.Is(err, context.Canceled) {
return nil
}
return err
}
if addr == "" {
addr = "127.0.0.1:8080"
}
var config net.ListenConfig
listener, err := config.Listen(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("web: listen: %w", err)
}
log.Printf("Agentkit HTTP: http://%s", listener.Addr())
err = serveWeb(ctx, listener, handler)
if errors.Is(err, context.Canceled) && ctx.Err() != nil {
return nil
}
return err
}

func serveWeb(ctx context.Context, listener net.Listener, handler http.Handler) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
srv := &http.Server{
Handler: handler, ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second, IdleTimeout: time.Minute,
BaseContext: func(net.Listener) context.Context { return ctx },
}
defer srv.Close()
served := make(chan error, 1)
go func() { served <- srv.Serve(listener) }()
select {
case err := <-served:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdown, stop := context.WithTimeout(context.Background(), 5*time.Second)
defer stop()
if err := srv.Shutdown(shutdown); err != nil {
return fmt.Errorf("launcher: shutdown: %w", err)
}
return ctx.Err()
}
}
Loading
Loading