diff options
| -rw-r--r-- | examples/simple-agent-go/go.mod | 7 | ||||
| -rw-r--r-- | examples/simple-agent-go/main.go | 152 | ||||
| -rw-r--r-- | libpanto-go/go.mod | 2 | ||||
| -rw-r--r-- | libpanto-go/panto.go | 41 | ||||
| -rw-r--r-- | libpanto-go/panto_test.go | 34 |
5 files changed, 222 insertions, 14 deletions
diff --git a/examples/simple-agent-go/go.mod b/examples/simple-agent-go/go.mod new file mode 100644 index 0000000..19a479c --- /dev/null +++ b/examples/simple-agent-go/go.mod @@ -0,0 +1,7 @@ +module simple-agent-go + +go 1.24 + +require code.tjp.lol/pantograph.git/libpanto-go v0.0.0 + +replace code.tjp.lol/pantograph.git/libpanto-go => ../../libpanto-go diff --git a/examples/simple-agent-go/main.go b/examples/simple-agent-go/main.go new file mode 100644 index 0000000..c733cd7 --- /dev/null +++ b/examples/simple-agent-go/main.go @@ -0,0 +1,152 @@ +// A tiny standalone libpanto-go app: one prompt in, streamed text out. +// +// Run with: +// +// PANTO_API_STYLE=openai_chat|anthropic_messages \ +// PANTO_API_KEY=... \ +// PANTO_BASE_URL=... \ +// PANTO_MODEL=... \ +// go run examples/simple-agent-go/main.go "list the files" + +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + + panto "code.tjp.lol/pantograph.git/libpanto-go" +) + +func main() { + panto.Init() + defer panto.Deinit() + + apiStyle := env("PANTO_API_STYLE", "openai_chat") + apiKey := requireEnv("PANTO_API_KEY") + baseURL := requireEnv("PANTO_BASE_URL") + model := requireEnv("PANTO_MODEL") + maxTokens := uint32(4096) + if v := os.Getenv("PANTO_MAX_TOKENS"); v != "" { + fmt.Sscanf(v, "%d", &maxTokens) + } + + prompt := strings.Join(os.Args[1:], " ") + if prompt == "" { + fmt.Fprintln(os.Stderr, "usage: simple-agent-go <prompt>") + os.Exit(2) + } + + var cfg panto.Config + if strings.HasPrefix(apiStyle, "anthropic") { + cfg.Provider = panto.ProviderConfig{ + AnthropicMessages: &panto.AnthropicMessagesConfig{ + APIKey: apiKey, + BaseURL: baseURL, + Model: model, + MaxTokens: maxTokens, + }, + } + } else { + cfg.Provider = panto.ProviderConfig{ + OpenAIChat: &panto.OpenAIChatConfig{ + APIKey: apiKey, + BaseURL: baseURL, + Model: model, + MaxTokens: maxTokens, + }, + } + } + + agent, err := panto.NewAgent(cfg) + if err != nil { + panic(err) + } + defer agent.Close() + + agent.SetSystemPrompt("You are a very small coding agent.\nUse the bash tool when you need to inspect or change the local workspace.\nKeep final answers concise.") + + agent.RegisterTool(panto.Tool{ + Decl: panto.ToolDecl{ + Name: "bash", + Description: "Run a shell command with bash -lc and return combined stdout/stderr plus the exit code.", + SchemaJSON: `{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The command to run." + } + }, + "required": ["command"] + }`, + }, + Invoke: func(input string) (panto.ResultParts, error) { + var params map[string]any + if err := json.Unmarshal([]byte(input), ¶ms); err != nil { + return nil, fmt.Errorf("invalid input: %w", err) + } + command, ok := params["command"].(string) + if !ok || command == "" { + return nil, fmt.Errorf("bash.command must be a non-empty string") + } + + out, err := runBash(command) + if err != nil { + return nil, err + } + return panto.ResultParts{{Text: out}}, nil + }, + }) + + stream, err := agent.Run(prompt) + if err != nil { + panic(err) + } + defer stream.Close() + + for ev := range stream.Iter() { + switch ev.Tag { + case panto.EventContentDelta: + fmt.Print(ev.ContentDelta.Delta) + os.Stdout.Sync() + case panto.EventToolDispatchStart: + fmt.Fprintf(os.Stderr, "\n[bash: running %d tool call(s)]\n", ev.ToolDispatchStart.Count) + case panto.EventToolDispatchComplete: + fmt.Fprint(os.Stderr, "[bash: done]\n") + } + } + if err := stream.Err(); err != nil { + panic(err) + } + fmt.Println() +} + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func requireEnv(key string) string { + v := os.Getenv(key) + if v == "" { + panic("missing required env var: " + key) + } + return v +} + +func runBash(command string) (string, error) { + cmd := exec.Command("bash", "-lc", command) + out, err := cmd.CombinedOutput() + status := "unknown" + if exitErr, ok := err.(*exec.ExitError); ok { + status = fmt.Sprintf("%d", exitErr.ExitCode()) + } else if err != nil { + return "", err + } + return fmt.Sprintf("exit_code=%s\n%s", status, string(out)), nil +} diff --git a/libpanto-go/go.mod b/libpanto-go/go.mod index 805022f..c8ccb75 100644 --- a/libpanto-go/go.mod +++ b/libpanto-go/go.mod @@ -1,3 +1,3 @@ -module github.com/travisjeffery/pantograph/libpanto-go +module code.tjp.lol/pantograph.git/libpanto-go go 1.24 diff --git a/libpanto-go/panto.go b/libpanto-go/panto.go index 786ff1c..5644b61 100644 --- a/libpanto-go/panto.go +++ b/libpanto-go/panto.go @@ -202,7 +202,12 @@ type ( ) type ( - Agent struct{ ptr *C.PantoAgent } + Agent struct { + ptr *C.PantoAgent + retainedSession *Session + retainedConv *Conversation + retainedStoreCloser func() + } Stream struct { ptr *C.PantoStream err error @@ -564,16 +569,7 @@ func (b ContentBlock) Snapshot() OwnedContentBlock { } func NewAgent(cfg Config) (*Agent, error) { - st, err := NewNullStore() - if err != nil { - return nil, err - } - sess, err := CreateSession(st) - st.Close() - if err != nil { - return nil, err - } - return NewAgentWithSession(cfg, sess, nil) + return NewAgentWithSession(cfg, nil, nil) } func NewAgentWithSession(cfg Config, session *Session, conv *Conversation) (*Agent, error) { @@ -582,6 +578,11 @@ func NewAgentWithSession(cfg Config, session *Session, conv *Conversation) (*Age return nil, err } defer strings.free() + var csession *C.PantoSession + if session != nil { + csession = session.ptr + runtime.SetFinalizer(session, nil) + } var cconv *C.PantoConversation if conv != nil { cconv = conv.ptr @@ -589,10 +590,10 @@ func NewAgentWithSession(cfg Config, session *Session, conv *Conversation) (*Age runtime.SetFinalizer(conv, nil) } var out *C.PantoAgent - if C.panto_agent_create(&ccfg, session.ptr, cconv, &out) != C.PANTO_OK { + if C.panto_agent_create(&ccfg, csession, cconv, &out) != C.PANTO_OK { return nil, lastError() } - a := &Agent{out} + a := &Agent{ptr: out, retainedSession: session, retainedConv: conv} runtime.SetFinalizer(a, (*Agent).Close) return a, nil } @@ -602,6 +603,20 @@ func (a *Agent) Close() { C.panto_agent_destroy(a.ptr) a.ptr = nil } + if a != nil { + if a.retainedSession != nil { + a.retainedSession.ptr = nil + a.retainedSession = nil + } + if a.retainedConv != nil { + a.retainedConv.ptr = nil + a.retainedConv = nil + } + if a.retainedStoreCloser != nil { + a.retainedStoreCloser() + a.retainedStoreCloser = nil + } + } } func (a *Agent) SessionID() string { return goSlice(C.panto_agent_session_id(a.ptr)) } func (a *Agent) Conversation() *Conversation { diff --git a/libpanto-go/panto_test.go b/libpanto-go/panto_test.go new file mode 100644 index 0000000..ef21f1e --- /dev/null +++ b/libpanto-go/panto_test.go @@ -0,0 +1,34 @@ +package panto + +import "testing" + +func testConfig() Config { + return Config{ + Provider: ProviderConfig{ + OpenAIChat: &OpenAIChatConfig{ + APIKey: "test-key", + BaseURL: "http://localhost", + Model: "test-model", + MaxTokens: 16, + }, + }, + } +} + +func TestNewAgentSetSystemPromptDoesNotCrash(t *testing.T) { + Init() + defer Deinit() + + a, err := NewAgent(testConfig()) + if err != nil { + t.Fatalf("NewAgent: %v", err) + } + defer a.Close() + + if err := a.SetSystemPrompt("be brief"); err != nil { + t.Fatalf("SetSystemPrompt: %v", err) + } + if err := a.AddSystemMessage("also be accurate"); err != nil { + t.Fatalf("AddSystemMessage: %v", err) + } +} |
