From 91c270db024c036a360f12052b2bd10e4b04a926 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 7 Jul 2026 12:28:16 -0600 Subject: move libpanto-go code into a panto subdirectory for package naming --- .gitignore | 3 + examples/simple-agent-go/main.go | 2 +- libpanto-go/README.md | 2 + libpanto-go/panto.go | 1592 -------------------------------------- libpanto-go/panto/panto.go | 1592 ++++++++++++++++++++++++++++++++++++++ libpanto-go/panto/panto_test.go | 213 +++++ libpanto-go/panto_test.go | 213 ----- 7 files changed, 1811 insertions(+), 1806 deletions(-) create mode 100644 .gitignore delete mode 100644 libpanto-go/panto.go create mode 100644 libpanto-go/panto/panto.go create mode 100644 libpanto-go/panto/panto_test.go delete mode 100644 libpanto-go/panto_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ce992d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +zig-pkg/ +.zig-cache/ +zig-out/ diff --git a/examples/simple-agent-go/main.go b/examples/simple-agent-go/main.go index 8a02142..b13982d 100644 --- a/examples/simple-agent-go/main.go +++ b/examples/simple-agent-go/main.go @@ -17,7 +17,7 @@ import ( "os/exec" "strings" - panto "code.tjp.lol/libpantograph.git/libpanto-go" + "code.tjp.lol/libpantograph.git/libpanto-go/panto" ) func main() { diff --git a/libpanto-go/README.md b/libpanto-go/README.md index 0b21137..c4da899 100644 --- a/libpanto-go/README.md +++ b/libpanto-go/README.md @@ -22,6 +22,8 @@ At runtime, make sure `../zig-out/lib` (or the installed equivalent) is on your ## Example ```go +import "code.tjp.lol/libpantograph.git/libpanto-go/panto" + panto.Init() defer panto.Deinit() diff --git a/libpanto-go/panto.go b/libpanto-go/panto.go deleted file mode 100644 index abaa84e..0000000 --- a/libpanto-go/panto.go +++ /dev/null @@ -1,1592 +0,0 @@ -package panto - -/* -#cgo CFLAGS: -I../libpanto-c/include -#cgo LDFLAGS: -L../zig-out/lib -lpanto -#cgo darwin LDFLAGS: -Wl,-rpath,${SRCDIR}/../zig-out/lib -#include -#include -extern PantoStatus pantoGoStoreCreate(void *ctx, PantoSession **out); -extern PantoStatus pantoGoStoreList(void *ctx, PantoSessionInfoList *out); -extern void pantoGoStoreFreeSessionInfos(void *ctx, PantoSessionInfoList infos); -extern PantoStatus pantoGoStoreResolve(void *ctx, PantoSlice id, PantoSessionResult *out); -extern PantoStatus pantoGoStoreLatest(void *ctx, PantoSessionResult *out); -extern PantoStatus pantoGoStoreLoad(void *ctx, PantoSlice id, PantoConversation **out); -extern PantoStatus pantoGoStoreAppendMessages(void *ctx, PantoSlice session_id, PantoPersistentMessage *messages, size_t len); -static PantoStatus pantoGoStoreAppendMessagesConst(void *ctx, PantoSlice session_id, const PantoPersistentMessage *messages, size_t len) { - return pantoGoStoreAppendMessages(ctx, session_id, (PantoPersistentMessage*)messages, len); -} -extern PantoStatus pantoGoToolInvoke(void *ctx, PantoSlice input, PantoResultParts *out); -extern void pantoGoCallbackDestroy(void *ctx); -static PantoSessionStoreVTable panto_go_store_vtable = { - pantoGoStoreCreate, - pantoGoStoreList, - pantoGoStoreFreeSessionInfos, - pantoGoStoreResolve, - pantoGoStoreLatest, - pantoGoStoreLoad, - pantoGoStoreAppendMessagesConst, - pantoGoCallbackDestroy, -}; -static PantoStatus panto_go_session_store_create(void *ctx, PantoSessionStore **out) { - return panto_session_store_create(ctx, &panto_go_store_vtable, out); -} -*/ -import "C" - -import ( - "errors" - "fmt" - "iter" - "runtime" - "unsafe" -) - -type ( - APIStyle int - ReasoningEffort int - Thinking int - Effort int - MessageRole int - ContentBlockTag int - EventTag int - SystemMode int -) - -// These enums mirror libpanto-c's C enums. Each constant wraps its C value -// (rather than a hand-kept iota sequence) so the Go side can never silently -// drift from the ABI: if a variant is inserted in panto.h, the values shift -// with it instead of desyncing. Keep one Go constant per C value. -const ( - OpenAIChat APIStyle = APIStyle(C.PANTO_OPENAI_CHAT) - AnthropicMessages APIStyle = APIStyle(C.PANTO_ANTHROPIC_MESSAGES) - OpenAIResponses APIStyle = APIStyle(C.PANTO_OPENAI_RESPONSES) - OpenAICodexResponses APIStyle = APIStyle(C.PANTO_OPENAI_CODEX_RESPONSES) -) - -const ( - ReasoningDefault ReasoningEffort = ReasoningEffort(C.PANTO_REASONING_DEFAULT) - ReasoningOff ReasoningEffort = ReasoningEffort(C.PANTO_REASONING_OFF) - ReasoningMinimal ReasoningEffort = ReasoningEffort(C.PANTO_REASONING_MINIMAL) - ReasoningLow ReasoningEffort = ReasoningEffort(C.PANTO_REASONING_LOW) - ReasoningMedium ReasoningEffort = ReasoningEffort(C.PANTO_REASONING_MEDIUM) - ReasoningHigh ReasoningEffort = ReasoningEffort(C.PANTO_REASONING_HIGH) -) - -const ( - ThinkingDisabled Thinking = Thinking(C.PANTO_THINKING_DISABLED) - ThinkingEnabled Thinking = Thinking(C.PANTO_THINKING_ENABLED) - ThinkingAdaptive Thinking = Thinking(C.PANTO_THINKING_ADAPTIVE) -) - -const ( - EffortLow Effort = Effort(C.PANTO_EFFORT_LOW) - EffortMedium Effort = Effort(C.PANTO_EFFORT_MEDIUM) - EffortHigh Effort = Effort(C.PANTO_EFFORT_HIGH) - EffortXHigh Effort = Effort(C.PANTO_EFFORT_XHIGH) - EffortMax Effort = Effort(C.PANTO_EFFORT_MAX) -) - -const ( - RoleSystem MessageRole = MessageRole(C.PANTO_ROLE_SYSTEM) - RoleUser MessageRole = MessageRole(C.PANTO_ROLE_USER) - RoleAssistant MessageRole = MessageRole(C.PANTO_ROLE_ASSISTANT) -) - -const ( - BlockText ContentBlockTag = ContentBlockTag(C.PANTO_BLOCK_TEXT) - BlockThinking ContentBlockTag = ContentBlockTag(C.PANTO_BLOCK_THINKING) - BlockToolUse ContentBlockTag = ContentBlockTag(C.PANTO_BLOCK_TOOL_USE) - BlockToolResult ContentBlockTag = ContentBlockTag(C.PANTO_BLOCK_TOOL_RESULT) - BlockSystem ContentBlockTag = ContentBlockTag(C.PANTO_BLOCK_SYSTEM) - BlockCompactionSummary ContentBlockTag = ContentBlockTag(C.PANTO_BLOCK_COMPACTION_SUMMARY) -) - -const ( - SystemAppend SystemMode = SystemMode(C.PANTO_SYSTEM_APPEND) - SystemReplace SystemMode = SystemMode(C.PANTO_SYSTEM_REPLACE) -) - -const ( - EventMessageStart EventTag = EventTag(C.PANTO_EVENT_MESSAGE_START) - EventBlockStart EventTag = EventTag(C.PANTO_EVENT_BLOCK_START) - EventToolDetails EventTag = EventTag(C.PANTO_EVENT_TOOL_DETAILS) - EventContentDelta EventTag = EventTag(C.PANTO_EVENT_CONTENT_DELTA) - EventBlockComplete EventTag = EventTag(C.PANTO_EVENT_BLOCK_COMPLETE) - EventMessageComplete EventTag = EventTag(C.PANTO_EVENT_MESSAGE_COMPLETE) - EventProviderRetry EventTag = EventTag(C.PANTO_EVENT_PROVIDER_RETRY) - EventToolDispatchStart EventTag = EventTag(C.PANTO_EVENT_TOOL_DISPATCH_START) - EventToolDispatchResult EventTag = EventTag(C.PANTO_EVENT_TOOL_DISPATCH_RESULT) - EventToolDispatchComplete EventTag = EventTag(C.PANTO_EVENT_TOOL_DISPATCH_COMPLETE) - EventTurnComplete EventTag = EventTag(C.PANTO_EVENT_TURN_COMPLETE) -) - -type Config struct { - Provider ProviderConfig - Compaction CompactionConfig - Retry RetryConfig -} -type ProviderConfig struct { - OpenAIChat *OpenAIChatConfig - AnthropicMessages *AnthropicMessagesConfig - OpenAIResponses *OpenAIResponsesConfig -} -type OpenAIChatConfig struct { - APIKey, BaseURL, Model string - Reasoning ReasoningEffort - MaxTokens uint32 -} -type OpenAIResponsesConfig struct { - APIKey, BaseURL, Model string - Reasoning ReasoningEffort - MaxTokens uint32 -} -type AnthropicMessagesConfig struct { - APIKey, BaseURL, Model, APIVersion string - MaxTokens uint32 - Thinking Thinking - Effort Effort - ThinkingBudgetTokens *uint32 - ThinkingInterleaved bool -} -type CompactionConfig struct { - KeepVerbatim uint32 - Model *ProviderConfig - CompactionPrompt *string -} -type RetryConfig struct { - MaxAttempts uint - InitialDelayMS, MaxDelayMS uint64 - Multiplier float64 - Jitter bool -} -type ( - Usage struct{ Input, Output, CacheRead, CacheWrite, Reasoning uint64 } - CompactionResult struct { - Compacted bool - KeptTurns, SummarizedMessages uint - } -) - -// Event is one streaming-progress notification from a Stream. The concrete -// type is one of the *Event structs below; type-switch on it, or branch on -// EventType() when only the kind matters. The terminal event of a turn is -// TurnCompleteEvent (EventType() == EventTurnComplete). -type Event interface { - // EventType reports the kind of event, mirroring the concrete type. It - // lets callers branch without a type switch (e.g. skip everything until - // EventMessageComplete) and is the cheap terminal check used by Iter. - EventType() EventTag -} - -// MessageStartEvent: an assistant message began streaming. -type MessageStartEvent struct{ Role MessageRole } - -// BlockStartEvent: a content block opened. -type BlockStartEvent struct { - BlockType ContentBlockTag - Index uint -} - -// ToolDetailsEvent: the tool identity (id + name) for a ToolUse block resolved. -type ToolDetailsEvent struct { - Index uint - ID, Name string -} - -// ContentDeltaEvent: streaming content for the open block (text/thinking/args). -type ContentDeltaEvent struct { - Index uint - Delta string -} - -// BlockCompleteEvent: a content block closed. -type BlockCompleteEvent struct { - Index uint - BlockType ContentBlockTag -} - -// MessageCompleteEvent: one assistant message finished streaming. The message -// is the current tail of Agent.Conversation(); MessageIndex is its index there -// and BlockCount its number of content blocks, so the full content can be read -// back via Agent.Conversation().Message(MessageIndex) without marshalling every -// block onto the event. Usage is the wire-reported token usage, if any. -type MessageCompleteEvent struct { - Role MessageRole - MessageIndex uint - BlockCount uint - Usage *Usage -} - -// ProviderRetryEvent: a provider retry was scheduled before the next attempt. -type ProviderRetryEvent struct { - Attempt, MaxAttempts uint - DelayMS uint64 - ErrorName string - StatusCode *uint16 - RetryAfterMS *uint64 - Message string - Compaction bool -} - -// ToolDispatchStartEvent: the agent began dispatching the just-completed -// message's tool calls. -type ToolDispatchStartEvent struct{ Count uint } - -// ToolDispatchResultEvent: a single tool's result became available, before the -// aggregate ToolDispatchCompleteEvent. MessageIndex is the index of the -// ToolResult-carrying message in the conversation. -type ToolDispatchResultEvent struct{ MessageIndex uint } - -// ToolDispatchCompleteEvent: the agent appended a user(ToolResult) message. -type ToolDispatchCompleteEvent struct{ MessageIndex uint } - -// TurnCompleteEvent: the turn terminal. Emitted exactly once; every Next after -// it returns nil. -type TurnCompleteEvent struct{} - -func (MessageStartEvent) EventType() EventTag { return EventMessageStart } -func (BlockStartEvent) EventType() EventTag { return EventBlockStart } -func (ToolDetailsEvent) EventType() EventTag { return EventToolDetails } -func (ContentDeltaEvent) EventType() EventTag { return EventContentDelta } -func (BlockCompleteEvent) EventType() EventTag { return EventBlockComplete } -func (MessageCompleteEvent) EventType() EventTag { return EventMessageComplete } -func (ProviderRetryEvent) EventType() EventTag { return EventProviderRetry } -func (ToolDispatchStartEvent) EventType() EventTag { return EventToolDispatchStart } -func (ToolDispatchResultEvent) EventType() EventTag { return EventToolDispatchResult } -func (ToolDispatchCompleteEvent) EventType() EventTag { return EventToolDispatchComplete } -func (TurnCompleteEvent) EventType() EventTag { return EventTurnComplete } - -type ( - Agent struct { - ptr *C.PantoAgent - retainedSession *Session - retainedConv *Conversation - retainedStoreCloser func() - } - Stream struct { - ptr *C.PantoStream - err error - done bool - } -) - -type Conversation struct { - ptr *C.PantoConversation - owned bool -} -type ( - Session struct{ ptr *C.PantoSession } - SessionInfo struct { - ID, Created, Modified, LastUserMessage, BaseURL, Model string - MessageCount uint - APIStyle APIStyle - Reasoning ReasoningEffort - } -) - -type SessionStore interface { - Create() (*Session, error) - List() ([]SessionInfo, error) - Resolve(id string) (*Session, bool, error) - Latest() (*Session, bool, error) - Load(id string) (*Conversation, bool, error) - AppendMessages(sessionID string, messages []PersistentMessage) error -} -type ( - cSessionStore interface{ cptr() *C.PantoSessionStore } - NullStore struct{ ptr *C.PantoSessionStore } - FileSystemJSONLStore struct{ ptr *C.PantoSessionStore } - GoSessionStore struct { - ptr *C.PantoSessionStore - impl SessionStore - } -) - -type PersistentMessage struct { - Role MessageRole - Usage *Usage - Metadata string - Content []OwnedContentBlock - // Per-message wire identity: the provider that produced this message. May - // drift within a conversation (compaction on another model, mid-chat - // switch), so persist these per message rather than from static config. - APIStyle APIStyle - BaseURL string - Model string - Reasoning ReasoningEffort -} -type OwnedMessage struct { - Role MessageRole - Usage *Usage - Metadata string - Content []OwnedContentBlock -} -type OwnedContentBlock struct { - Tag ContentBlockTag - Text string - ToolID string - ToolName string - IsError bool - SystemMode SystemMode - // Thinking-block signature provenance. Populated for blocks delivered to - // AppendMessages; empty otherwise. To replay a thinking block faithfully, - // persist all four and feed them back via ThinkingBlock — Anthropic drops - // a thinking block whose signature lacks a matching origin. - ThinkingSignature string - ThinkingSignatureStyle APIStyle - ThinkingSignatureBaseURL string - ThinkingSignatureModel string -} - -type cStrings []*C.char - -func (cs cStrings) free() { - for _, s := range cs { - C.free(unsafe.Pointer(s)) - } -} -func Init() { C.panto_init() } -func Deinit() { C.panto_deinit() } -func lastError() error { - s := C.panto_last_error() - if s.ptr == nil || s.len == 0 { - return errors.New("libpanto error") - } - return errors.New(C.GoStringN((*C.char)(unsafe.Pointer(s.ptr)), C.int(s.len))) -} - -func NewNullStore() (*NullStore, error) { - var out *C.PantoSessionStore - if C.panto_null_store_create(&out) != C.PANTO_OK { - return nil, lastError() - } - s := &NullStore{out} - runtime.SetFinalizer(s, (*NullStore).Close) - return s, nil -} -func (s *NullStore) cptr() *C.PantoSessionStore { return s.ptr } -func (s *NullStore) Create() (*Session, error) { return CreateSession(s) } -func (s *NullStore) List() ([]SessionInfo, error) { return ListSessions(s) } -func (s *NullStore) Resolve(id string) (*Session, bool, error) { return ResolveSession(s, id) } -func (s *NullStore) Latest() (*Session, bool, error) { return LatestSession(s) } -func (s *NullStore) Load(id string) (*Conversation, bool, error) { - sess, ok, err := ResolveSession(s, id) - if err != nil || !ok { - return nil, ok, err - } - c, err := sess.Load() - return c, err == nil, err -} -func (s *NullStore) AppendMessages(sessionID string, messages []PersistentMessage) error { return nil } -func (s *NullStore) Close() { - if s != nil && s.ptr != nil { - C.panto_session_store_destroy(s.ptr) - s.ptr = nil - } -} - -func NewFileSystemJSONLStore(dir, metadata string) (*FileSystemJSONLStore, error) { - cd := C.CString(dir) - defer C.free(unsafe.Pointer(cd)) - cm := C.CString(metadata) - defer C.free(unsafe.Pointer(cm)) - var out *C.PantoSessionStore - if C.panto_file_system_jsonl_store_create(cd, cm, &out) != C.PANTO_OK { - return nil, lastError() - } - s := &FileSystemJSONLStore{out} - runtime.SetFinalizer(s, (*FileSystemJSONLStore).Close) - return s, nil -} -func (s *FileSystemJSONLStore) cptr() *C.PantoSessionStore { return s.ptr } -func (s *FileSystemJSONLStore) Create() (*Session, error) { return CreateSession(s) } -func (s *FileSystemJSONLStore) List() ([]SessionInfo, error) { return ListSessions(s) } -func (s *FileSystemJSONLStore) Resolve(id string) (*Session, bool, error) { - return ResolveSession(s, id) -} -func (s *FileSystemJSONLStore) Latest() (*Session, bool, error) { return LatestSession(s) } -func (s *FileSystemJSONLStore) Load(id string) (*Conversation, bool, error) { - sess, ok, err := ResolveSession(s, id) - if err != nil || !ok { - return nil, ok, err - } - c, err := sess.Load() - return c, err == nil, err -} - -func (s *FileSystemJSONLStore) AppendMessages(sessionID string, messages []PersistentMessage) error { - return nil -} - -func (s *FileSystemJSONLStore) Close() { - if s != nil && s.ptr != nil { - C.panto_session_store_destroy(s.ptr) - s.ptr = nil - } -} - -func CreateSession(store cSessionStore) (*Session, error) { - var out *C.PantoSession - if C.panto_session_store_create_session(store.cptr(), &out) != C.PANTO_OK { - return nil, lastError() - } - s := &Session{out} - runtime.SetFinalizer(s, (*Session).Close) - return s, nil -} - -// NewSession mints a *Session bound to store, carrying info (at least a -// store-assigned ID). Unlike CreateSession/ResolveSession/LatestSession it does -// NOT dispatch into the store's vtable, so a custom SessionStore can call it -// from its own Create/Resolve/Latest to build the *Session it must return -// without recursing back into itself. The session's bound store is used for -// later load/append; pass the store the session belongs to. -func NewSession(store cSessionStore, info SessionInfo) (*Session, error) { - ci := cSessionInfo(info) - defer freeCSessionInfo(ci) - var out *C.PantoSession - if C.panto_session_create(store.cptr(), ci, &out) != C.PANTO_OK { - return nil, lastError() - } - s := &Session{out} - runtime.SetFinalizer(s, (*Session).Close) - return s, nil -} - -// NewNullSession mints a standalone *Session backed by a null store — load and -// append are no-ops. Useful as a placeholder when a store performs its own -// persistence outside the session's bound store. -func NewNullSession() (*Session, error) { - var out *C.PantoSession - if C.panto_session_create_null(&out) != C.PANTO_OK { - return nil, lastError() - } - s := &Session{out} - runtime.SetFinalizer(s, (*Session).Close) - return s, nil -} - -func ResolveSession(store cSessionStore, id string) (*Session, bool, error) { - cid := C.CString(id) - defer C.free(unsafe.Pointer(cid)) - var r C.PantoSessionResult - if C.panto_session_store_resolve(store.cptr(), cid, &r) != C.PANTO_OK { - return nil, false, lastError() - } - if !bool(r.found) { - return nil, false, nil - } - s := &Session{r.session} - runtime.SetFinalizer(s, (*Session).Close) - return s, true, nil -} - -func LatestSession(store cSessionStore) (*Session, bool, error) { - var r C.PantoSessionResult - if C.panto_session_store_latest(store.cptr(), &r) != C.PANTO_OK { - return nil, false, lastError() - } - if !bool(r.found) { - return nil, false, nil - } - s := &Session{r.session} - runtime.SetFinalizer(s, (*Session).Close) - return s, true, nil -} - -func ListSessions(store cSessionStore) ([]SessionInfo, error) { - var l C.PantoSessionInfoList - if C.panto_session_store_list(store.cptr(), &l) != C.PANTO_OK { - return nil, lastError() - } - defer C.panto_session_info_list_destroy(l) - out := make([]SessionInfo, int(l.len)) - if l.ptr != nil { - xs := unsafe.Slice(l.ptr, int(l.len)) - for i, x := range xs { - out[i] = sessionInfo(x) - } - } - return out, nil -} - -func (s *Session) Close() { - if s != nil && s.ptr != nil { - C.panto_session_destroy(s.ptr) - s.ptr = nil - } -} - -func (s *Session) Load() (*Conversation, error) { - var out *C.PantoConversation - if C.panto_session_load(s.ptr, &out) != C.PANTO_OK { - return nil, lastError() - } - c := &Conversation{ptr: out, owned: true} - runtime.SetFinalizer(c, (*Conversation).Close) - return c, nil -} - -func NewConversation() (*Conversation, error) { - var out *C.PantoConversation - if C.panto_conversation_create(&out) != C.PANTO_OK { - return nil, lastError() - } - c := &Conversation{ptr: out, owned: true} - runtime.SetFinalizer(c, (*Conversation).Close) - return c, nil -} - -func (c *Conversation) Close() { - if c != nil && c.owned && c.ptr != nil { - C.panto_conversation_destroy(c.ptr) - c.ptr = nil - } -} -func (c *Conversation) MessageCount() uint { return uint(C.panto_conversation_message_count(c.ptr)) } -func (c *Conversation) Message(i uint) Message { return Message{conv: c, index: i} } -func (c *Conversation) AddSystemMessage(text string) error { - ct := C.CString(text) - defer C.free(unsafe.Pointer(ct)) - if C.panto_conversation_add_system_message(c.ptr, ct) != C.PANTO_OK { - return lastError() - } - return nil -} - -func (c *Conversation) ReplaceSystemMessage(text string) error { - ct := C.CString(text) - defer C.free(unsafe.Pointer(ct)) - if C.panto_conversation_replace_system_message(c.ptr, ct) != C.PANTO_OK { - return lastError() - } - return nil -} - -func (c *Conversation) AddUserText(text string) error { - ct := C.CString(text) - defer C.free(unsafe.Pointer(ct)) - if C.panto_conversation_add_user_text(c.ptr, ct) != C.PANTO_OK { - return lastError() - } - return nil -} - -func (c *Conversation) AddAssistantText(text string, u *Usage) error { - ct := C.CString(text) - defer C.free(unsafe.Pointer(ct)) - var cu C.PantoUsage - has := false - if u != nil { - cu = cUsage(*u) - has = true - } - if C.panto_conversation_add_assistant_text(c.ptr, ct, C.bool(has), cu) != C.PANTO_OK { - return lastError() - } - return nil -} - -func (c *Conversation) AddCompactionSummary(text string) error { - ct := C.CString(text) - defer C.free(unsafe.Pointer(ct)) - if C.panto_conversation_add_compaction_summary(c.ptr, ct) != C.PANTO_OK { - return lastError() - } - return nil -} - -// Block is one immutable content block — text, thinking, tool use, tool -// result, or system text. Build it with the constructors below (TextBlock, -// ToolResultBlock, …) and assemble blocks into a message with UserMessage / -// AssistantMessage. This is the single content-block vocabulary used both to -// open a turn (Agent.Run) and to rebuild a persisted message for resumption -// (Conversation.AddMessage). -type Block struct { - tag ContentBlockTag - text string - toolID string - toolName string - input string - isError bool - systemMode SystemMode - thinkingSignature string - thinkingSigStyle APIStyle - thinkingSigBaseURL string - thinkingSigModel string -} - -// TextBlock is a plain text block. -func TextBlock(text string) Block { return Block{tag: BlockText, text: text} } - -// ThinkingBlock is an assistant reasoning block. signature/baseURL/model carry -// the signature provenance (see OwnedContentBlock); pass "" for signature when -// the provider emitted none, in which case style/baseURL/model are ignored. -func ThinkingBlock(text, signature string, style APIStyle, baseURL, model string) Block { - return Block{tag: BlockThinking, text: text, thinkingSignature: signature, thinkingSigStyle: style, thinkingSigBaseURL: baseURL, thinkingSigModel: model} -} - -// ToolUseBlock is an assistant tool call. -func ToolUseBlock(id, name, input string) Block { - return Block{tag: BlockToolUse, toolID: id, toolName: name, input: input} -} - -// ToolResultBlock is a user-role tool result keyed by the originating tool-use -// id. Used to resume a turn whose tool calls the embedder handled itself. -func ToolResultBlock(toolUseID, text string, isError bool) Block { - return Block{tag: BlockToolResult, toolID: toolUseID, text: text, isError: isError} -} - -// SystemBlock is a system-prompt block (append or replace mode). -func SystemBlock(text string, mode SystemMode) Block { - return Block{tag: BlockSystem, text: text, systemMode: mode} -} - -// MessageIdentity is the wire identity (provider style/baseURL/model + -// reasoning) that produced a message. Attach it to an InputMessage when -// rebuilding a conversation for resumption: it is the per-message source of -// truth for replaying thinking signatures and is preserved through compaction, -// so a kept-verbatim turn is not re-stamped with the compaction model. Set it -// from a PersistentMessage's APIStyle/BaseURL/Model/Reasoning. -type MessageIdentity struct { - APIStyle APIStyle - BaseURL string - Model string - Reasoning ReasoningEffort -} - -// InputMessage is one role-tagged message assembled from value Blocks, with -// optional usage and wire identity. Build it with UserMessage / AssistantMessage -// (or set the fields directly), then pass it to Agent.Run (user role) or -// Conversation.AddMessage (any role, for replay). -type InputMessage struct { - Role MessageRole - Usage *Usage - Identity *MessageIdentity - Blocks []Block -} - -// UserMessage assembles a user-role message — the argument to Agent.Run. A -// plain chat turn is a single TextBlock; a turn resuming after embedder-handled -// tool calls is one or more ToolResultBlocks. -func UserMessage(blocks ...Block) InputMessage { - return InputMessage{Role: RoleUser, Blocks: blocks} -} - -// AssistantMessage assembles an assistant-role message with optional usage — -// used to rebuild a persisted turn via Conversation.AddMessage. -func AssistantMessage(usage *Usage, blocks ...Block) InputMessage { - return InputMessage{Role: RoleAssistant, Usage: usage, Blocks: blocks} -} - -// WithIdentity returns a copy of the message tagged with the given wire -// identity (see MessageIdentity). -func (m InputMessage) WithIdentity(id MessageIdentity) InputMessage { - m.Identity = &id - return m -} - -// buildCBuilder materializes an InputMessage as a transient C message builder, -// pushing each block in order and attaching identity if present. The returned -// builder is owned by the caller and must be consumed by exactly one of -// panto_agent_run / panto_conversation_add_message (both of which destroy it). -// On error it is destroyed here and nil is returned. -func buildCBuilder(msg InputMessage) (*C.PantoMessageBuilder, error) { - p := C.panto_message_builder_create(C.PantoMessageRole(msg.Role)) - if p == nil { - return nil, lastError() - } - committed := false - defer func() { - if !committed { - C.panto_message_builder_destroy(p) - } - }() - if msg.Identity != nil { - cb := C.CString(msg.Identity.BaseURL) - defer C.free(unsafe.Pointer(cb)) - cm := C.CString(msg.Identity.Model) - defer C.free(unsafe.Pointer(cm)) - if C.panto_message_builder_set_identity(p, C.PantoAPIStyle(msg.Identity.APIStyle), cb, cm, C.PantoReasoningEffort(msg.Identity.Reasoning)) != C.PANTO_OK { - return nil, lastError() - } - } - for _, b := range msg.Blocks { - if err := pushBlock(p, b); err != nil { - return nil, err - } - } - committed = true - return p, nil -} - -func pushBlock(p *C.PantoMessageBuilder, b Block) error { - switch b.tag { - case BlockText: - ct := C.CString(b.text) - defer C.free(unsafe.Pointer(ct)) - if C.panto_message_builder_add_text(p, ct) != C.PANTO_OK { - return lastError() - } - case BlockThinking: - ct := C.CString(b.text) - defer C.free(unsafe.Pointer(ct)) - cs := C.CString(b.thinkingSignature) - defer C.free(unsafe.Pointer(cs)) - cb := C.CString(b.thinkingSigBaseURL) - defer C.free(unsafe.Pointer(cb)) - cm := C.CString(b.thinkingSigModel) - defer C.free(unsafe.Pointer(cm)) - if C.panto_message_builder_add_thinking(p, ct, cs, C.PantoAPIStyle(b.thinkingSigStyle), cb, cm) != C.PANTO_OK { - return lastError() - } - case BlockToolUse: - cid := C.CString(b.toolID) - defer C.free(unsafe.Pointer(cid)) - cn := C.CString(b.toolName) - defer C.free(unsafe.Pointer(cn)) - ci := C.CString(b.input) - defer C.free(unsafe.Pointer(ci)) - if C.panto_message_builder_add_tool_use(p, cid, cn, ci) != C.PANTO_OK { - return lastError() - } - case BlockToolResult: - cid := C.CString(b.toolID) - defer C.free(unsafe.Pointer(cid)) - ct := C.CString(b.text) - defer C.free(unsafe.Pointer(ct)) - if C.panto_message_builder_add_tool_result(p, cid, ct, C.bool(b.isError)) != C.PANTO_OK { - return lastError() - } - case BlockSystem: - ct := C.CString(b.text) - defer C.free(unsafe.Pointer(ct)) - if C.panto_message_builder_add_system(p, ct, C.PantoSystemMode(b.systemMode)) != C.PANTO_OK { - return lastError() - } - default: - return fmt.Errorf("unknown content block tag %d", int(b.tag)) - } - return nil -} - -// AddMessage commits msg as one message of msg.Role (with its optional usage -// and identity) — the block-level path for rebuilding a persisted conversation -// containing tool calls or thinking that the text-only AddUserText/ -// AddAssistantText cannot. -func (c *Conversation) AddMessage(msg InputMessage) error { - b, err := buildCBuilder(msg) - if err != nil { - return err - } - var cu C.PantoUsage - has := false - if msg.Usage != nil { - cu = cUsage(*msg.Usage) - has = true - } - // panto_conversation_add_message consumes (destroys) the builder on every - // outcome, so there is nothing to free here afterwards. - if C.panto_conversation_add_message(c.ptr, b, C.bool(has), cu) != C.PANTO_OK { - return lastError() - } - return nil -} - -type Message struct { - conv *Conversation - index uint -} - -func (m Message) Role() MessageRole { - return MessageRole(C.panto_conversation_message_role(m.conv.ptr, C.size_t(m.index))) -} - -func (m Message) Usage() *Usage { - if !bool(C.panto_conversation_message_has_usage(m.conv.ptr, C.size_t(m.index))) { - return nil - } - u := usage(C.panto_conversation_message_usage(m.conv.ptr, C.size_t(m.index))) - return &u -} - -func (m Message) BlockCount() uint { - return uint(C.panto_conversation_message_block_count(m.conv.ptr, C.size_t(m.index))) -} - -func (m Message) Block(i uint) ContentBlock { - return ContentBlock{conv: m.conv, msg: m.index, index: i} -} - -func (m Message) Snapshot() OwnedMessage { - blocks := make([]OwnedContentBlock, m.BlockCount()) - for i := range blocks { - blocks[i] = m.Block(uint(i)).Snapshot() - } - return OwnedMessage{Role: m.Role(), Usage: m.Usage(), Content: blocks} -} - -type ContentBlock struct { - conv *Conversation - msg, index uint -} - -func (b ContentBlock) Tag() ContentBlockTag { - return ContentBlockTag(C.panto_conversation_block_tag(b.conv.ptr, C.size_t(b.msg), C.size_t(b.index))) -} - -func (b ContentBlock) Text() string { - return goSlice(C.panto_conversation_block_text(b.conv.ptr, C.size_t(b.msg), C.size_t(b.index))) -} - -func (b ContentBlock) ToolID() string { - return goSlice(C.panto_conversation_block_tool_id(b.conv.ptr, C.size_t(b.msg), C.size_t(b.index))) -} - -func (b ContentBlock) ToolName() string { - return goSlice(C.panto_conversation_block_tool_name(b.conv.ptr, C.size_t(b.msg), C.size_t(b.index))) -} - -func (b ContentBlock) ToolResultIsError() bool { - return bool(C.panto_conversation_block_tool_result_is_error(b.conv.ptr, C.size_t(b.msg), C.size_t(b.index))) -} - -func (b ContentBlock) SystemMode() SystemMode { - return SystemMode(C.panto_conversation_block_system_mode(b.conv.ptr, C.size_t(b.msg), C.size_t(b.index))) -} - -func (b ContentBlock) Snapshot() OwnedContentBlock { - return OwnedContentBlock{ - Tag: b.Tag(), - Text: b.Text(), - ToolID: b.ToolID(), - ToolName: b.ToolName(), - IsError: b.ToolResultIsError(), - SystemMode: b.SystemMode(), - } -} - -func NewAgent(cfg Config) (*Agent, error) { - return NewAgentWithSession(cfg, nil, nil) -} - -func NewAgentWithSession(cfg Config, session *Session, conv *Conversation) (*Agent, error) { - ccfg, strings, err := cfg.c() - if err != nil { - 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 - conv.owned = false - runtime.SetFinalizer(conv, nil) - } - var out *C.PantoAgent - if C.panto_agent_create(&ccfg, csession, cconv, &out) != C.PANTO_OK { - return nil, lastError() - } - a := &Agent{ptr: out, retainedSession: session, retainedConv: conv} - runtime.SetFinalizer(a, (*Agent).Close) - return a, nil -} - -func (a *Agent) Close() { - if a != nil && a.ptr != nil { - 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 { - return &Conversation{ptr: C.panto_agent_conversation(a.ptr), owned: false} -} - -func (a *Agent) SetConfig(cfg Config) error { - ccfg, strings, err := cfg.c() - if err != nil { - return err - } - defer strings.free() - if C.panto_agent_set_config(a.ptr, &ccfg) != C.PANTO_OK { - return lastError() - } - return nil -} - -func (a *Agent) Compact(overrideSystemPrompt, extraInstructions *string) (CompactionResult, error) { - var co, ce *C.char - if overrideSystemPrompt != nil { - co = C.CString(*overrideSystemPrompt) - defer C.free(unsafe.Pointer(co)) - } - if extraInstructions != nil { - ce = C.CString(*extraInstructions) - defer C.free(unsafe.Pointer(ce)) - } - var out C.PantoCompactionResult - if C.panto_agent_compact(a.ptr, co, ce, &out) != C.PANTO_OK { - return CompactionResult{}, lastError() - } - return CompactionResult{ - Compacted: bool(out.compacted), - KeptTurns: uint(out.kept_turns), - SummarizedMessages: uint(out.summarized_messages), - }, nil -} - -// Run opens a turn from a user-role message and returns a resumable Stream. -// Build the message with UserMessage: a single TextBlock for a plain prompt, -// or ToolResultBlocks to resume a turn whose tool calls the embedder handled -// itself after breaking out of a prior stream. The message must be user-role. -func (a *Agent) Run(msg InputMessage) (*Stream, error) { - if msg.Role != RoleUser { - return nil, fmt.Errorf("Run requires a user-role message, got role %d", int(msg.Role)) - } - b, err := buildCBuilder(msg) - if err != nil { - return nil, err - } - // panto_agent_run consumes (destroys) the builder on every outcome. - var out *C.PantoStream - if C.panto_agent_run(a.ptr, b, &out) != C.PANTO_OK { - return nil, lastError() - } - s := &Stream{ptr: out} - runtime.SetFinalizer(s, (*Stream).Close) - return s, nil -} - -func (a *Agent) AddSystemMessage(text string) error { - ct := C.CString(text) - defer C.free(unsafe.Pointer(ct)) - if C.panto_agent_add_system_message(a.ptr, ct) != C.PANTO_OK { - return lastError() - } - return nil -} - -func (a *Agent) SetSystemPrompt(text string) error { - ct := C.CString(text) - defer C.free(unsafe.Pointer(ct)) - if C.panto_agent_set_system_prompt(a.ptr, ct) != C.PANTO_OK { - return lastError() - } - return nil -} - -func (s *Stream) Close() { - if s != nil && s.ptr != nil { - C.panto_stream_destroy(s.ptr) - s.ptr = nil - } -} - -// Reopen resets a failed stream back to its turn-open boundary so the caller -// can resume Next() after changing the agent config (e.g. catch a terminal -// bad-request, swap the provider config via Agent.SetConfig, and retry the -// SAME turn without re-appending the user message). It is valid only on a -// stream whose Next() has surfaced a terminal error; otherwise it returns an -// error. On success the stream's local error/done state is cleared so the -// iteration can continue. -func (s *Stream) Reopen() error { - if s == nil || s.ptr == nil { - return fmt.Errorf("reopen on a closed stream") - } - if C.panto_stream_reopen(s.ptr) != C.PANTO_OK { - return lastError() - } - s.err = nil - s.done = false - return nil -} - -func (s *Stream) Next() (Event, bool, error) { - if s.err != nil { - return nil, false, s.err - } - if s.done { - return nil, false, nil - } - var cev C.PantoEvent - status := C.panto_stream_next(s.ptr, &cev) - switch status { - case C.PANTO_NEXT_EVENT: - defer C.panto_event_free(&cev) - ev := marshalEvent(&cev) - if ev.EventType() == EventTurnComplete { - s.done = true - } - return ev, true, nil - case C.PANTO_NEXT_DONE: - s.done = true - return nil, false, nil - case C.PANTO_NEXT_ERROR: - s.err = lastError() - return nil, false, s.err - default: - s.err = fmt.Errorf("unknown panto next status %d", int(status)) - return nil, false, s.err - } -} -func (s *Stream) Err() error { return s.err } -func (s *Stream) Iter() iter.Seq[Event] { - return func(yield func(Event) bool) { - for { - ev, ok, err := s.Next() - if err != nil || !ok { - return - } - if !yield(ev) { - return - } - if ev.EventType() == EventTurnComplete { - return - } - } - } -} - -func (s *Stream) Chan() <-chan Event { - ch := make(chan Event) - go func() { - defer close(ch) - for ev := range s.Iter() { - ch <- ev - } - }() - return ch -} - -func (cfg Config) c() (C.PantoConfig, cStrings, error) { - var cs cStrings - provider, pstrings, err := cfg.Provider.c() - cs = append(cs, pstrings...) - if err != nil { - cs.free() - return C.PantoConfig{}, nil, err - } - out := C.PantoConfig{provider: provider} - out.compaction.keep_verbatim = C.uint32_t(cfg.Compaction.KeepVerbatim) - if cfg.Compaction.Model != nil { - m, ms, err := cfg.Compaction.Model.c() - cs = append(cs, ms...) - if err != nil { - cs.free() - return C.PantoConfig{}, nil, err - } - out.compaction.has_model = true - out.compaction.model = m - } - if cfg.Compaction.CompactionPrompt != nil { - c := C.CString(*cfg.Compaction.CompactionPrompt) - cs = append(cs, c) - out.compaction.has_compaction_prompt = true - out.compaction.compaction_prompt = c - } - out.retry.max_attempts = C.size_t(cfg.Retry.MaxAttempts) - out.retry.initial_delay_ms = C.uint64_t(cfg.Retry.InitialDelayMS) - out.retry.max_delay_ms = C.uint64_t(cfg.Retry.MaxDelayMS) - out.retry.multiplier = C.double(cfg.Retry.Multiplier) - out.retry.jitter = C.bool(cfg.Retry.Jitter) - return out, cs, nil -} - -func (p ProviderConfig) c() (C.PantoProviderConfig, cStrings, error) { - var out C.PantoProviderConfig - var cs cStrings - n := 0 - if p.OpenAIChat != nil { - n++ - } - if p.AnthropicMessages != nil { - n++ - } - if p.OpenAIResponses != nil { - n++ - } - if n != 1 { - return out, nil, errors.New("exactly one provider config must be set") - } - if p.OpenAIChat != nil { - out.tag = C.PANTO_OPENAI_CHAT - setOpenAI(&out, p.OpenAIChat, &cs) - return out, cs, nil - } - if p.OpenAIResponses != nil { - out.tag = C.PANTO_OPENAI_RESPONSES - setOpenAIResponses(&out, p.OpenAIResponses, &cs) - return out, cs, nil - } - out.tag = C.PANTO_ANTHROPIC_MESSAGES - setAnthropic(&out, p.AnthropicMessages, &cs) - return out, cs, nil -} - -func setOpenAI(out *C.PantoProviderConfig, cfg *OpenAIChatConfig, cs *cStrings) { - apiKey, baseURL, model := C.CString(cfg.APIKey), C.CString(cfg.BaseURL), C.CString(cfg.Model) - *cs = append(*cs, apiKey, baseURL, model) - c := (*C.PantoOpenAIChatConfig)(unsafe.Pointer(&out.data[0])) - c.api_key = apiKey - c.base_url = baseURL - c.model = model - c.reasoning = C.PantoReasoningEffort(cfg.Reasoning) - c.max_tokens = C.uint32_t(cfg.MaxTokens) -} - -func setOpenAIResponses(out *C.PantoProviderConfig, cfg *OpenAIResponsesConfig, cs *cStrings) { - apiKey, baseURL, model := C.CString(cfg.APIKey), C.CString(cfg.BaseURL), C.CString(cfg.Model) - *cs = append(*cs, apiKey, baseURL, model) - c := (*C.PantoOpenAIResponsesConfig)(unsafe.Pointer(&out.data[0])) - c.api_key = apiKey - c.base_url = baseURL - c.model = model - c.reasoning = C.PantoReasoningEffort(cfg.Reasoning) - c.max_tokens = C.uint32_t(cfg.MaxTokens) -} - -func setAnthropic(out *C.PantoProviderConfig, cfg *AnthropicMessagesConfig, cs *cStrings) { - apiKey, baseURL, model, ver := C.CString(cfg.APIKey), C.CString(cfg.BaseURL), C.CString(cfg.Model), C.CString(cfg.APIVersion) - *cs = append(*cs, apiKey, baseURL, model, ver) - c := (*C.PantoAnthropicMessagesConfig)(unsafe.Pointer(&out.data[0])) - c.api_key = apiKey - c.base_url = baseURL - c.model = model - c.api_version = ver - c.max_tokens = C.uint32_t(cfg.MaxTokens) - c.thinking = C.PantoThinking(cfg.Thinking) - c.effort = C.PantoEffort(cfg.Effort) - c.thinking_interleaved = C.bool(cfg.ThinkingInterleaved) - if cfg.ThinkingBudgetTokens != nil { - c.has_thinking_budget_tokens = true - c.thinking_budget_tokens = C.uint32_t(*cfg.ThinkingBudgetTokens) - } -} - -func marshalEvent(cev *C.PantoEvent) Event { - switch cev.tag { - case C.PANTO_EVENT_MESSAGE_START: - return MessageStartEvent{Role: MessageRole(*(*C.PantoMessageRole)(unsafe.Pointer(&cev.data[0])))} - case C.PANTO_EVENT_BLOCK_START: - b := (*C.PantoBlockStart)(unsafe.Pointer(&cev.data[0])) - return BlockStartEvent{BlockType: ContentBlockTag(b.block_type), Index: uint(b.index)} - case C.PANTO_EVENT_TOOL_DETAILS: - t := (*C.PantoToolDetails)(unsafe.Pointer(&cev.data[0])) - return ToolDetailsEvent{Index: uint(t.index), ID: goSlice(t.id), Name: goSlice(t.name)} - case C.PANTO_EVENT_CONTENT_DELTA: - d := (*C.PantoContentDelta)(unsafe.Pointer(&cev.data[0])) - return ContentDeltaEvent{Index: uint(d.index), Delta: goSlice(d.delta)} - case C.PANTO_EVENT_BLOCK_COMPLETE: - b := (*C.PantoBlockComplete)(unsafe.Pointer(&cev.data[0])) - return BlockCompleteEvent{Index: uint(b.index), BlockType: ContentBlockTag(b.block_type)} - case C.PANTO_EVENT_MESSAGE_COMPLETE: - m := (*C.PantoMessageComplete)(unsafe.Pointer(&cev.data[0])) - ev := MessageCompleteEvent{ - Role: MessageRole(m.role), - MessageIndex: uint(m.message_index), - BlockCount: uint(m.block_count), - } - if m.has_usage { - u := usage(m.usage) - ev.Usage = &u - } - return ev - case C.PANTO_EVENT_PROVIDER_RETRY: - r := (*C.PantoProviderRetry)(unsafe.Pointer(&cev.data[0])) - ev := ProviderRetryEvent{ - Attempt: uint(r.attempt), - MaxAttempts: uint(r.max_attempts), - DelayMS: uint64(r.delay_ms), - ErrorName: goSlice(r.error_name), - Message: goSlice(r.message), - Compaction: bool(r.compaction), - } - if r.has_status_code { - v := uint16(r.status_code) - ev.StatusCode = &v - } - if r.has_retry_after_ms { - v := uint64(r.retry_after_ms) - ev.RetryAfterMS = &v - } - return ev - case C.PANTO_EVENT_TOOL_DISPATCH_START: - t := (*C.PantoToolDispatchStart)(unsafe.Pointer(&cev.data[0])) - return ToolDispatchStartEvent{Count: uint(t.count)} - case C.PANTO_EVENT_TOOL_DISPATCH_RESULT: - t := (*C.PantoToolDispatchComplete)(unsafe.Pointer(&cev.data[0])) - return ToolDispatchResultEvent{MessageIndex: uint(t.message_index)} - case C.PANTO_EVENT_TOOL_DISPATCH_COMPLETE: - t := (*C.PantoToolDispatchComplete)(unsafe.Pointer(&cev.data[0])) - return ToolDispatchCompleteEvent{MessageIndex: uint(t.message_index)} - case C.PANTO_EVENT_TURN_COMPLETE: - return TurnCompleteEvent{} - } - return TurnCompleteEvent{} -} - -func goSlice(s C.PantoSlice) string { - if s.ptr == nil || s.len == 0 { - return "" - } - return C.GoStringN((*C.char)(unsafe.Pointer(s.ptr)), C.int(s.len)) -} - -func usage(u C.PantoUsage) Usage { - return Usage{ - Input: uint64(u.input), - Output: uint64(u.output), - CacheRead: uint64(u.cache_read), - CacheWrite: uint64(u.cache_write), - Reasoning: uint64(u.reasoning), - } -} - -func cUsage(u Usage) C.PantoUsage { - return C.PantoUsage{ - input: C.uint64_t(u.Input), - output: C.uint64_t(u.Output), - cache_read: C.uint64_t(u.CacheRead), - cache_write: C.uint64_t(u.CacheWrite), - reasoning: C.uint64_t(u.Reasoning), - } -} - -func sessionInfo(i C.PantoSessionInfo) SessionInfo { - return SessionInfo{ - ID: goSlice(i.id), - Created: goSlice(i.created), - Modified: goSlice(i.modified), - LastUserMessage: goSlice(i.last_user_message), - BaseURL: goSlice(i.base_url), - Model: goSlice(i.model), - MessageCount: uint(i.message_count), - APIStyle: APIStyle(i.api_style), - Reasoning: ReasoningEffort(i.reasoning), - } -} - -// Tool callback support. -type ( - ToolDecl struct{ Name, Description, SchemaJSON string } - ResultPart struct { - Text string - // Media carries raw bytes, not base64. libpanto detects/resizes/encodes - // supported images and PDFs for the provider; MediaType is an optional - // MIME hint. - MediaType string - Media []byte - } - ResultParts []ResultPart - ToolFunc func(input string) (ResultParts, error) - Tool struct { - Decl ToolDecl - Invoke ToolFunc - } -) - -var ( - callbackHandles = map[uintptr]any{} - nextCallbackHandle uintptr = 1 -) - -func saveCallback(v any) unsafe.Pointer { - h := nextCallbackHandle - nextCallbackHandle++ - callbackHandles[h] = v - return unsafe.Pointer(h) -} -func dropCallback(p unsafe.Pointer) { delete(callbackHandles, uintptr(p)) } - -//export pantoGoToolInvoke -func pantoGoToolInvoke(ctx unsafe.Pointer, input C.PantoSlice, out *C.PantoResultParts) C.PantoStatus { - t := callbackHandles[uintptr(ctx)].(Tool) - parts, err := t.Invoke(goSlice(input)) - if err != nil { - return C.PANTO_ERROR - } - *out = makeCResultParts(parts) - return C.PANTO_OK -} - -//export pantoGoCallbackDestroy -func pantoGoCallbackDestroy(ctx unsafe.Pointer) { dropCallback(ctx) } - -// RegisterTool registers a single tool. Note: libpanto invokes each tool call -// on its own thread, so Invoke may run concurrently — guard any shared state. -// -// TODO: back all Go tool registrations with one process-global Zig ToolSource so -// libpanto delivers the whole turn's batch on a single thread and we fan out onto -// goroutines instead of libpanto threads. Pure efficiency (cheaper than N pthreads -// per turn); the public API stays exactly this. Punted as not worth the work yet. -func (a *Agent) RegisterTool(t Tool) error { - ctx := saveCallback(t) - d, free := cToolDecl(t.Decl) - defer free() - if C.panto_agent_register_tool(a.ptr, d, ctx, (C.PantoToolInvokeFn)(C.pantoGoToolInvoke), (C.PantoToolDestroyFn)(C.pantoGoCallbackDestroy)) != C.PANTO_OK { - dropCallback(ctx) - return lastError() - } - return nil -} - -func cToolDecl(d ToolDecl) (C.PantoToolDecl, func()) { - n := cSlice(d.Name) - desc := cSlice(d.Description) - schema := cSlice(d.SchemaJSON) - return C.PantoToolDecl{name: n, description: desc, schema_json: schema}, func() { - C.free(unsafe.Pointer(n.ptr)) - C.free(unsafe.Pointer(desc.ptr)) - C.free(unsafe.Pointer(schema.ptr)) - } -} - -func cSlice(s string) C.PantoSlice { - cs := C.CString(s) - return C.PantoSlice{ptr: (*C.uint8_t)(unsafe.Pointer(cs)), len: C.size_t(len(s))} -} - -func makeCResultParts(parts ResultParts) C.PantoResultParts { - if len(parts) == 0 { - return C.PantoResultParts{} - } - arr := (*C.PantoResultPart)(C.calloc(C.size_t(len(parts)), C.size_t(unsafe.Sizeof(C.PantoResultPart{})))) - xs := unsafe.Slice(arr, len(parts)) - for i, p := range parts { - if p.Media == nil { - cs := cSlice(p.Text) - xs[i].tag = C.PANTO_RESULT_TEXT - (*C.PantoSlice)(unsafe.Pointer(&xs[i].data[0])).ptr = cs.ptr - (*C.PantoSlice)(unsafe.Pointer(&xs[i].data[0])).len = cs.len - continue - } - - xs[i].tag = C.PANTO_RESULT_MEDIA - m := (*C.PantoMediaPart)(unsafe.Pointer(&xs[i].data[0])) - m.data = C.PantoSlice{ptr: (*C.uint8_t)(C.CBytes(p.Media)), len: C.size_t(len(p.Media))} - if p.MediaType != "" { - m.media_type = cSlice(p.MediaType) - m.has_media_type = C.bool(true) - } - } - return C.PantoResultParts{ptr: arr, len: C.size_t(len(parts))} -} - -func NewGoSessionStore(impl SessionStore) (*GoSessionStore, error) { - ctx := saveCallback(impl) - var out *C.PantoSessionStore - if C.panto_go_session_store_create(ctx, &out) != C.PANTO_OK { - dropCallback(ctx) - return nil, lastError() - } - s := &GoSessionStore{ptr: out, impl: impl} - runtime.SetFinalizer(s, (*GoSessionStore).Close) - return s, nil -} -func (s *GoSessionStore) cptr() *C.PantoSessionStore { return s.ptr } -func (s *GoSessionStore) Create() (*Session, error) { return CreateSession(s) } -func (s *GoSessionStore) List() ([]SessionInfo, error) { return ListSessions(s) } -func (s *GoSessionStore) Resolve(id string) (*Session, bool, error) { return ResolveSession(s, id) } -func (s *GoSessionStore) Latest() (*Session, bool, error) { return LatestSession(s) } -func (s *GoSessionStore) Load(id string) (*Conversation, bool, error) { return s.impl.Load(id) } -func (s *GoSessionStore) AppendMessages(sessionID string, messages []PersistentMessage) error { - return s.impl.AppendMessages(sessionID, messages) -} - -func (s *GoSessionStore) Close() { - if s != nil && s.ptr != nil { - C.panto_session_store_destroy(s.ptr) - s.ptr = nil - } -} - -//export pantoGoStoreCreate -func pantoGoStoreCreate(ctx unsafe.Pointer, out **C.PantoSession) C.PantoStatus { - impl := callbackHandles[uintptr(ctx)].(SessionStore) - s, err := impl.Create() - if err != nil { - return C.PANTO_ERROR - } - *out = s.ptr - runtime.SetFinalizer(s, nil) - return C.PANTO_OK -} - -//export pantoGoStoreList -func pantoGoStoreList(ctx unsafe.Pointer, out *C.PantoSessionInfoList) C.PantoStatus { - impl := callbackHandles[uintptr(ctx)].(SessionStore) - infos, err := impl.List() - if err != nil { - return C.PANTO_ERROR - } - if len(infos) == 0 { - *out = C.PantoSessionInfoList{} - return C.PANTO_OK - } - arr := (*C.PantoSessionInfo)(C.calloc(C.size_t(len(infos)), C.size_t(unsafe.Sizeof(C.PantoSessionInfo{})))) - xs := unsafe.Slice(arr, len(infos)) - for i, info := range infos { - xs[i] = cSessionInfo(info) - } - *out = C.PantoSessionInfoList{ptr: arr, len: C.size_t(len(infos))} - return C.PANTO_OK -} - -//export pantoGoStoreFreeSessionInfos -func pantoGoStoreFreeSessionInfos(ctx unsafe.Pointer, infos C.PantoSessionInfoList) { - _ = ctx - freeCSessionInfoList(infos) -} - -//export pantoGoStoreResolve -func pantoGoStoreResolve(ctx unsafe.Pointer, id C.PantoSlice, out *C.PantoSessionResult) C.PantoStatus { - impl := callbackHandles[uintptr(ctx)].(SessionStore) - s, ok, err := impl.Resolve(goSlice(id)) - if err != nil { - return C.PANTO_ERROR - } - if !ok { - *out = C.PantoSessionResult{} - return C.PANTO_OK - } - *out = C.PantoSessionResult{found: true, session: s.ptr} - runtime.SetFinalizer(s, nil) - return C.PANTO_OK -} - -//export pantoGoStoreLatest -func pantoGoStoreLatest(ctx unsafe.Pointer, out *C.PantoSessionResult) C.PantoStatus { - impl := callbackHandles[uintptr(ctx)].(SessionStore) - s, ok, err := impl.Latest() - if err != nil { - return C.PANTO_ERROR - } - if !ok { - *out = C.PantoSessionResult{} - return C.PANTO_OK - } - *out = C.PantoSessionResult{found: true, session: s.ptr} - runtime.SetFinalizer(s, nil) - return C.PANTO_OK -} - -//export pantoGoStoreLoad -func pantoGoStoreLoad(ctx unsafe.Pointer, id C.PantoSlice, out **C.PantoConversation) C.PantoStatus { - impl := callbackHandles[uintptr(ctx)].(SessionStore) - c, ok, err := impl.Load(goSlice(id)) - if err != nil { - return C.PANTO_ERROR - } - if !ok { - return C.PANTO_ERROR - } - *out = c.ptr - c.owned = false - runtime.SetFinalizer(c, nil) - return C.PANTO_OK -} - -//export pantoGoStoreAppendMessages -func pantoGoStoreAppendMessages(ctx unsafe.Pointer, sessionID C.PantoSlice, messages *C.PantoPersistentMessage, n C.size_t) C.PantoStatus { - impl := callbackHandles[uintptr(ctx)].(SessionStore) - xs := unsafe.Slice(messages, int(n)) - out := make([]PersistentMessage, int(n)) - for i, m := range xs { - var u *Usage - if m.has_usage { - uu := usage(m.usage) - u = &uu - } - out[i] = PersistentMessage{ - Role: MessageRole(m.role), - Usage: u, - Metadata: goSlice(m.metadata), - Content: goOwnedBlocks(m.content, m.content_len), - APIStyle: APIStyle(m.api_style), - BaseURL: goSlice(m.base_url), - Model: goSlice(m.model), - Reasoning: ReasoningEffort(m.reasoning), - } - } - if err := impl.AppendMessages(goSlice(sessionID), out); err != nil { - return C.PANTO_ERROR - } - return C.PANTO_OK -} - -func goOwnedBlocks(ptr *C.PantoOwnedContentBlock, n C.size_t) []OwnedContentBlock { - if ptr == nil || n == 0 { - return nil - } - xs := unsafe.Slice(ptr, int(n)) - out := make([]OwnedContentBlock, int(n)) - for i, b := range xs { - out[i] = OwnedContentBlock{ - Tag: ContentBlockTag(b.tag), - Text: goSlice(b.text), - ToolID: goSlice(b.tool_id), - ToolName: goSlice(b.tool_name), - IsError: bool(b.is_error), - SystemMode: SystemMode(b.system_mode), - ThinkingSignature: goSlice(b.thinking_signature), - ThinkingSignatureStyle: APIStyle(b.thinking_signature_api_style), - ThinkingSignatureBaseURL: goSlice(b.thinking_signature_base_url), - ThinkingSignatureModel: goSlice(b.thinking_signature_model), - } - } - return out -} - -func cSessionInfo(i SessionInfo) C.PantoSessionInfo { - return C.PantoSessionInfo{ - id: cSlice(i.ID), - created: cSlice(i.Created), - modified: cSlice(i.Modified), - last_user_message: cSlice(i.LastUserMessage), - base_url: cSlice(i.BaseURL), - model: cSlice(i.Model), - message_count: C.size_t(i.MessageCount), - api_style: C.PantoAPIStyle(i.APIStyle), - reasoning: C.PantoReasoningEffort(i.Reasoning), - } -} - -func freeCSessionInfo(i C.PantoSessionInfo) { - C.free(unsafe.Pointer(i.id.ptr)) - C.free(unsafe.Pointer(i.created.ptr)) - C.free(unsafe.Pointer(i.modified.ptr)) - C.free(unsafe.Pointer(i.last_user_message.ptr)) - C.free(unsafe.Pointer(i.base_url.ptr)) - C.free(unsafe.Pointer(i.model.ptr)) -} - -func freeCSessionInfoList(l C.PantoSessionInfoList) { - if l.ptr == nil { - return - } - xs := unsafe.Slice(l.ptr, int(l.len)) - for _, i := range xs { - freeCSessionInfo(i) - } - C.free(unsafe.Pointer(l.ptr)) -} diff --git a/libpanto-go/panto/panto.go b/libpanto-go/panto/panto.go new file mode 100644 index 0000000..88ff4e4 --- /dev/null +++ b/libpanto-go/panto/panto.go @@ -0,0 +1,1592 @@ +package panto + +/* +#cgo CFLAGS: -I../../libpanto-c/include +#cgo LDFLAGS: -L../../zig-out/lib -lpanto +#cgo darwin LDFLAGS: -Wl,-rpath,${SRCDIR}/../../zig-out/lib +#include +#include +extern PantoStatus pantoGoStoreCreate(void *ctx, PantoSession **out); +extern PantoStatus pantoGoStoreList(void *ctx, PantoSessionInfoList *out); +extern void pantoGoStoreFreeSessionInfos(void *ctx, PantoSessionInfoList infos); +extern PantoStatus pantoGoStoreResolve(void *ctx, PantoSlice id, PantoSessionResult *out); +extern PantoStatus pantoGoStoreLatest(void *ctx, PantoSessionResult *out); +extern PantoStatus pantoGoStoreLoad(void *ctx, PantoSlice id, PantoConversation **out); +extern PantoStatus pantoGoStoreAppendMessages(void *ctx, PantoSlice session_id, PantoPersistentMessage *messages, size_t len); +static PantoStatus pantoGoStoreAppendMessagesConst(void *ctx, PantoSlice session_id, const PantoPersistentMessage *messages, size_t len) { + return pantoGoStoreAppendMessages(ctx, session_id, (PantoPersistentMessage*)messages, len); +} +extern PantoStatus pantoGoToolInvoke(void *ctx, PantoSlice input, PantoResultParts *out); +extern void pantoGoCallbackDestroy(void *ctx); +static PantoSessionStoreVTable panto_go_store_vtable = { + pantoGoStoreCreate, + pantoGoStoreList, + pantoGoStoreFreeSessionInfos, + pantoGoStoreResolve, + pantoGoStoreLatest, + pantoGoStoreLoad, + pantoGoStoreAppendMessagesConst, + pantoGoCallbackDestroy, +}; +static PantoStatus panto_go_session_store_create(void *ctx, PantoSessionStore **out) { + return panto_session_store_create(ctx, &panto_go_store_vtable, out); +} +*/ +import "C" + +import ( + "errors" + "fmt" + "iter" + "runtime" + "unsafe" +) + +type ( + APIStyle int + ReasoningEffort int + Thinking int + Effort int + MessageRole int + ContentBlockTag int + EventTag int + SystemMode int +) + +// These enums mirror libpanto-c's C enums. Each constant wraps its C value +// (rather than a hand-kept iota sequence) so the Go side can never silently +// drift from the ABI: if a variant is inserted in panto.h, the values shift +// with it instead of desyncing. Keep one Go constant per C value. +const ( + OpenAIChat APIStyle = APIStyle(C.PANTO_OPENAI_CHAT) + AnthropicMessages APIStyle = APIStyle(C.PANTO_ANTHROPIC_MESSAGES) + OpenAIResponses APIStyle = APIStyle(C.PANTO_OPENAI_RESPONSES) + OpenAICodexResponses APIStyle = APIStyle(C.PANTO_OPENAI_CODEX_RESPONSES) +) + +const ( + ReasoningDefault ReasoningEffort = ReasoningEffort(C.PANTO_REASONING_DEFAULT) + ReasoningOff ReasoningEffort = ReasoningEffort(C.PANTO_REASONING_OFF) + ReasoningMinimal ReasoningEffort = ReasoningEffort(C.PANTO_REASONING_MINIMAL) + ReasoningLow ReasoningEffort = ReasoningEffort(C.PANTO_REASONING_LOW) + ReasoningMedium ReasoningEffort = ReasoningEffort(C.PANTO_REASONING_MEDIUM) + ReasoningHigh ReasoningEffort = ReasoningEffort(C.PANTO_REASONING_HIGH) +) + +const ( + ThinkingDisabled Thinking = Thinking(C.PANTO_THINKING_DISABLED) + ThinkingEnabled Thinking = Thinking(C.PANTO_THINKING_ENABLED) + ThinkingAdaptive Thinking = Thinking(C.PANTO_THINKING_ADAPTIVE) +) + +const ( + EffortLow Effort = Effort(C.PANTO_EFFORT_LOW) + EffortMedium Effort = Effort(C.PANTO_EFFORT_MEDIUM) + EffortHigh Effort = Effort(C.PANTO_EFFORT_HIGH) + EffortXHigh Effort = Effort(C.PANTO_EFFORT_XHIGH) + EffortMax Effort = Effort(C.PANTO_EFFORT_MAX) +) + +const ( + RoleSystem MessageRole = MessageRole(C.PANTO_ROLE_SYSTEM) + RoleUser MessageRole = MessageRole(C.PANTO_ROLE_USER) + RoleAssistant MessageRole = MessageRole(C.PANTO_ROLE_ASSISTANT) +) + +const ( + BlockText ContentBlockTag = ContentBlockTag(C.PANTO_BLOCK_TEXT) + BlockThinking ContentBlockTag = ContentBlockTag(C.PANTO_BLOCK_THINKING) + BlockToolUse ContentBlockTag = ContentBlockTag(C.PANTO_BLOCK_TOOL_USE) + BlockToolResult ContentBlockTag = ContentBlockTag(C.PANTO_BLOCK_TOOL_RESULT) + BlockSystem ContentBlockTag = ContentBlockTag(C.PANTO_BLOCK_SYSTEM) + BlockCompactionSummary ContentBlockTag = ContentBlockTag(C.PANTO_BLOCK_COMPACTION_SUMMARY) +) + +const ( + SystemAppend SystemMode = SystemMode(C.PANTO_SYSTEM_APPEND) + SystemReplace SystemMode = SystemMode(C.PANTO_SYSTEM_REPLACE) +) + +const ( + EventMessageStart EventTag = EventTag(C.PANTO_EVENT_MESSAGE_START) + EventBlockStart EventTag = EventTag(C.PANTO_EVENT_BLOCK_START) + EventToolDetails EventTag = EventTag(C.PANTO_EVENT_TOOL_DETAILS) + EventContentDelta EventTag = EventTag(C.PANTO_EVENT_CONTENT_DELTA) + EventBlockComplete EventTag = EventTag(C.PANTO_EVENT_BLOCK_COMPLETE) + EventMessageComplete EventTag = EventTag(C.PANTO_EVENT_MESSAGE_COMPLETE) + EventProviderRetry EventTag = EventTag(C.PANTO_EVENT_PROVIDER_RETRY) + EventToolDispatchStart EventTag = EventTag(C.PANTO_EVENT_TOOL_DISPATCH_START) + EventToolDispatchResult EventTag = EventTag(C.PANTO_EVENT_TOOL_DISPATCH_RESULT) + EventToolDispatchComplete EventTag = EventTag(C.PANTO_EVENT_TOOL_DISPATCH_COMPLETE) + EventTurnComplete EventTag = EventTag(C.PANTO_EVENT_TURN_COMPLETE) +) + +type Config struct { + Provider ProviderConfig + Compaction CompactionConfig + Retry RetryConfig +} +type ProviderConfig struct { + OpenAIChat *OpenAIChatConfig + AnthropicMessages *AnthropicMessagesConfig + OpenAIResponses *OpenAIResponsesConfig +} +type OpenAIChatConfig struct { + APIKey, BaseURL, Model string + Reasoning ReasoningEffort + MaxTokens uint32 +} +type OpenAIResponsesConfig struct { + APIKey, BaseURL, Model string + Reasoning ReasoningEffort + MaxTokens uint32 +} +type AnthropicMessagesConfig struct { + APIKey, BaseURL, Model, APIVersion string + MaxTokens uint32 + Thinking Thinking + Effort Effort + ThinkingBudgetTokens *uint32 + ThinkingInterleaved bool +} +type CompactionConfig struct { + KeepVerbatim uint32 + Model *ProviderConfig + CompactionPrompt *string +} +type RetryConfig struct { + MaxAttempts uint + InitialDelayMS, MaxDelayMS uint64 + Multiplier float64 + Jitter bool +} +type ( + Usage struct{ Input, Output, CacheRead, CacheWrite, Reasoning uint64 } + CompactionResult struct { + Compacted bool + KeptTurns, SummarizedMessages uint + } +) + +// Event is one streaming-progress notification from a Stream. The concrete +// type is one of the *Event structs below; type-switch on it, or branch on +// EventType() when only the kind matters. The terminal event of a turn is +// TurnCompleteEvent (EventType() == EventTurnComplete). +type Event interface { + // EventType reports the kind of event, mirroring the concrete type. It + // lets callers branch without a type switch (e.g. skip everything until + // EventMessageComplete) and is the cheap terminal check used by Iter. + EventType() EventTag +} + +// MessageStartEvent: an assistant message began streaming. +type MessageStartEvent struct{ Role MessageRole } + +// BlockStartEvent: a content block opened. +type BlockStartEvent struct { + BlockType ContentBlockTag + Index uint +} + +// ToolDetailsEvent: the tool identity (id + name) for a ToolUse block resolved. +type ToolDetailsEvent struct { + Index uint + ID, Name string +} + +// ContentDeltaEvent: streaming content for the open block (text/thinking/args). +type ContentDeltaEvent struct { + Index uint + Delta string +} + +// BlockCompleteEvent: a content block closed. +type BlockCompleteEvent struct { + Index uint + BlockType ContentBlockTag +} + +// MessageCompleteEvent: one assistant message finished streaming. The message +// is the current tail of Agent.Conversation(); MessageIndex is its index there +// and BlockCount its number of content blocks, so the full content can be read +// back via Agent.Conversation().Message(MessageIndex) without marshalling every +// block onto the event. Usage is the wire-reported token usage, if any. +type MessageCompleteEvent struct { + Role MessageRole + MessageIndex uint + BlockCount uint + Usage *Usage +} + +// ProviderRetryEvent: a provider retry was scheduled before the next attempt. +type ProviderRetryEvent struct { + Attempt, MaxAttempts uint + DelayMS uint64 + ErrorName string + StatusCode *uint16 + RetryAfterMS *uint64 + Message string + Compaction bool +} + +// ToolDispatchStartEvent: the agent began dispatching the just-completed +// message's tool calls. +type ToolDispatchStartEvent struct{ Count uint } + +// ToolDispatchResultEvent: a single tool's result became available, before the +// aggregate ToolDispatchCompleteEvent. MessageIndex is the index of the +// ToolResult-carrying message in the conversation. +type ToolDispatchResultEvent struct{ MessageIndex uint } + +// ToolDispatchCompleteEvent: the agent appended a user(ToolResult) message. +type ToolDispatchCompleteEvent struct{ MessageIndex uint } + +// TurnCompleteEvent: the turn terminal. Emitted exactly once; every Next after +// it returns nil. +type TurnCompleteEvent struct{} + +func (MessageStartEvent) EventType() EventTag { return EventMessageStart } +func (BlockStartEvent) EventType() EventTag { return EventBlockStart } +func (ToolDetailsEvent) EventType() EventTag { return EventToolDetails } +func (ContentDeltaEvent) EventType() EventTag { return EventContentDelta } +func (BlockCompleteEvent) EventType() EventTag { return EventBlockComplete } +func (MessageCompleteEvent) EventType() EventTag { return EventMessageComplete } +func (ProviderRetryEvent) EventType() EventTag { return EventProviderRetry } +func (ToolDispatchStartEvent) EventType() EventTag { return EventToolDispatchStart } +func (ToolDispatchResultEvent) EventType() EventTag { return EventToolDispatchResult } +func (ToolDispatchCompleteEvent) EventType() EventTag { return EventToolDispatchComplete } +func (TurnCompleteEvent) EventType() EventTag { return EventTurnComplete } + +type ( + Agent struct { + ptr *C.PantoAgent + retainedSession *Session + retainedConv *Conversation + retainedStoreCloser func() + } + Stream struct { + ptr *C.PantoStream + err error + done bool + } +) + +type Conversation struct { + ptr *C.PantoConversation + owned bool +} +type ( + Session struct{ ptr *C.PantoSession } + SessionInfo struct { + ID, Created, Modified, LastUserMessage, BaseURL, Model string + MessageCount uint + APIStyle APIStyle + Reasoning ReasoningEffort + } +) + +type SessionStore interface { + Create() (*Session, error) + List() ([]SessionInfo, error) + Resolve(id string) (*Session, bool, error) + Latest() (*Session, bool, error) + Load(id string) (*Conversation, bool, error) + AppendMessages(sessionID string, messages []PersistentMessage) error +} +type ( + cSessionStore interface{ cptr() *C.PantoSessionStore } + NullStore struct{ ptr *C.PantoSessionStore } + FileSystemJSONLStore struct{ ptr *C.PantoSessionStore } + GoSessionStore struct { + ptr *C.PantoSessionStore + impl SessionStore + } +) + +type PersistentMessage struct { + Role MessageRole + Usage *Usage + Metadata string + Content []OwnedContentBlock + // Per-message wire identity: the provider that produced this message. May + // drift within a conversation (compaction on another model, mid-chat + // switch), so persist these per message rather than from static config. + APIStyle APIStyle + BaseURL string + Model string + Reasoning ReasoningEffort +} +type OwnedMessage struct { + Role MessageRole + Usage *Usage + Metadata string + Content []OwnedContentBlock +} +type OwnedContentBlock struct { + Tag ContentBlockTag + Text string + ToolID string + ToolName string + IsError bool + SystemMode SystemMode + // Thinking-block signature provenance. Populated for blocks delivered to + // AppendMessages; empty otherwise. To replay a thinking block faithfully, + // persist all four and feed them back via ThinkingBlock — Anthropic drops + // a thinking block whose signature lacks a matching origin. + ThinkingSignature string + ThinkingSignatureStyle APIStyle + ThinkingSignatureBaseURL string + ThinkingSignatureModel string +} + +type cStrings []*C.char + +func (cs cStrings) free() { + for _, s := range cs { + C.free(unsafe.Pointer(s)) + } +} +func Init() { C.panto_init() } +func Deinit() { C.panto_deinit() } +func lastError() error { + s := C.panto_last_error() + if s.ptr == nil || s.len == 0 { + return errors.New("libpanto error") + } + return errors.New(C.GoStringN((*C.char)(unsafe.Pointer(s.ptr)), C.int(s.len))) +} + +func NewNullStore() (*NullStore, error) { + var out *C.PantoSessionStore + if C.panto_null_store_create(&out) != C.PANTO_OK { + return nil, lastError() + } + s := &NullStore{out} + runtime.SetFinalizer(s, (*NullStore).Close) + return s, nil +} +func (s *NullStore) cptr() *C.PantoSessionStore { return s.ptr } +func (s *NullStore) Create() (*Session, error) { return CreateSession(s) } +func (s *NullStore) List() ([]SessionInfo, error) { return ListSessions(s) } +func (s *NullStore) Resolve(id string) (*Session, bool, error) { return ResolveSession(s, id) } +func (s *NullStore) Latest() (*Session, bool, error) { return LatestSession(s) } +func (s *NullStore) Load(id string) (*Conversation, bool, error) { + sess, ok, err := ResolveSession(s, id) + if err != nil || !ok { + return nil, ok, err + } + c, err := sess.Load() + return c, err == nil, err +} +func (s *NullStore) AppendMessages(sessionID string, messages []PersistentMessage) error { return nil } +func (s *NullStore) Close() { + if s != nil && s.ptr != nil { + C.panto_session_store_destroy(s.ptr) + s.ptr = nil + } +} + +func NewFileSystemJSONLStore(dir, metadata string) (*FileSystemJSONLStore, error) { + cd := C.CString(dir) + defer C.free(unsafe.Pointer(cd)) + cm := C.CString(metadata) + defer C.free(unsafe.Pointer(cm)) + var out *C.PantoSessionStore + if C.panto_file_system_jsonl_store_create(cd, cm, &out) != C.PANTO_OK { + return nil, lastError() + } + s := &FileSystemJSONLStore{out} + runtime.SetFinalizer(s, (*FileSystemJSONLStore).Close) + return s, nil +} +func (s *FileSystemJSONLStore) cptr() *C.PantoSessionStore { return s.ptr } +func (s *FileSystemJSONLStore) Create() (*Session, error) { return CreateSession(s) } +func (s *FileSystemJSONLStore) List() ([]SessionInfo, error) { return ListSessions(s) } +func (s *FileSystemJSONLStore) Resolve(id string) (*Session, bool, error) { + return ResolveSession(s, id) +} +func (s *FileSystemJSONLStore) Latest() (*Session, bool, error) { return LatestSession(s) } +func (s *FileSystemJSONLStore) Load(id string) (*Conversation, bool, error) { + sess, ok, err := ResolveSession(s, id) + if err != nil || !ok { + return nil, ok, err + } + c, err := sess.Load() + return c, err == nil, err +} + +func (s *FileSystemJSONLStore) AppendMessages(sessionID string, messages []PersistentMessage) error { + return nil +} + +func (s *FileSystemJSONLStore) Close() { + if s != nil && s.ptr != nil { + C.panto_session_store_destroy(s.ptr) + s.ptr = nil + } +} + +func CreateSession(store cSessionStore) (*Session, error) { + var out *C.PantoSession + if C.panto_session_store_create_session(store.cptr(), &out) != C.PANTO_OK { + return nil, lastError() + } + s := &Session{out} + runtime.SetFinalizer(s, (*Session).Close) + return s, nil +} + +// NewSession mints a *Session bound to store, carrying info (at least a +// store-assigned ID). Unlike CreateSession/ResolveSession/LatestSession it does +// NOT dispatch into the store's vtable, so a custom SessionStore can call it +// from its own Create/Resolve/Latest to build the *Session it must return +// without recursing back into itself. The session's bound store is used for +// later load/append; pass the store the session belongs to. +func NewSession(store cSessionStore, info SessionInfo) (*Session, error) { + ci := cSessionInfo(info) + defer freeCSessionInfo(ci) + var out *C.PantoSession + if C.panto_session_create(store.cptr(), ci, &out) != C.PANTO_OK { + return nil, lastError() + } + s := &Session{out} + runtime.SetFinalizer(s, (*Session).Close) + return s, nil +} + +// NewNullSession mints a standalone *Session backed by a null store — load and +// append are no-ops. Useful as a placeholder when a store performs its own +// persistence outside the session's bound store. +func NewNullSession() (*Session, error) { + var out *C.PantoSession + if C.panto_session_create_null(&out) != C.PANTO_OK { + return nil, lastError() + } + s := &Session{out} + runtime.SetFinalizer(s, (*Session).Close) + return s, nil +} + +func ResolveSession(store cSessionStore, id string) (*Session, bool, error) { + cid := C.CString(id) + defer C.free(unsafe.Pointer(cid)) + var r C.PantoSessionResult + if C.panto_session_store_resolve(store.cptr(), cid, &r) != C.PANTO_OK { + return nil, false, lastError() + } + if !bool(r.found) { + return nil, false, nil + } + s := &Session{r.session} + runtime.SetFinalizer(s, (*Session).Close) + return s, true, nil +} + +func LatestSession(store cSessionStore) (*Session, bool, error) { + var r C.PantoSessionResult + if C.panto_session_store_latest(store.cptr(), &r) != C.PANTO_OK { + return nil, false, lastError() + } + if !bool(r.found) { + return nil, false, nil + } + s := &Session{r.session} + runtime.SetFinalizer(s, (*Session).Close) + return s, true, nil +} + +func ListSessions(store cSessionStore) ([]SessionInfo, error) { + var l C.PantoSessionInfoList + if C.panto_session_store_list(store.cptr(), &l) != C.PANTO_OK { + return nil, lastError() + } + defer C.panto_session_info_list_destroy(l) + out := make([]SessionInfo, int(l.len)) + if l.ptr != nil { + xs := unsafe.Slice(l.ptr, int(l.len)) + for i, x := range xs { + out[i] = sessionInfo(x) + } + } + return out, nil +} + +func (s *Session) Close() { + if s != nil && s.ptr != nil { + C.panto_session_destroy(s.ptr) + s.ptr = nil + } +} + +func (s *Session) Load() (*Conversation, error) { + var out *C.PantoConversation + if C.panto_session_load(s.ptr, &out) != C.PANTO_OK { + return nil, lastError() + } + c := &Conversation{ptr: out, owned: true} + runtime.SetFinalizer(c, (*Conversation).Close) + return c, nil +} + +func NewConversation() (*Conversation, error) { + var out *C.PantoConversation + if C.panto_conversation_create(&out) != C.PANTO_OK { + return nil, lastError() + } + c := &Conversation{ptr: out, owned: true} + runtime.SetFinalizer(c, (*Conversation).Close) + return c, nil +} + +func (c *Conversation) Close() { + if c != nil && c.owned && c.ptr != nil { + C.panto_conversation_destroy(c.ptr) + c.ptr = nil + } +} +func (c *Conversation) MessageCount() uint { return uint(C.panto_conversation_message_count(c.ptr)) } +func (c *Conversation) Message(i uint) Message { return Message{conv: c, index: i} } +func (c *Conversation) AddSystemMessage(text string) error { + ct := C.CString(text) + defer C.free(unsafe.Pointer(ct)) + if C.panto_conversation_add_system_message(c.ptr, ct) != C.PANTO_OK { + return lastError() + } + return nil +} + +func (c *Conversation) ReplaceSystemMessage(text string) error { + ct := C.CString(text) + defer C.free(unsafe.Pointer(ct)) + if C.panto_conversation_replace_system_message(c.ptr, ct) != C.PANTO_OK { + return lastError() + } + return nil +} + +func (c *Conversation) AddUserText(text string) error { + ct := C.CString(text) + defer C.free(unsafe.Pointer(ct)) + if C.panto_conversation_add_user_text(c.ptr, ct) != C.PANTO_OK { + return lastError() + } + return nil +} + +func (c *Conversation) AddAssistantText(text string, u *Usage) error { + ct := C.CString(text) + defer C.free(unsafe.Pointer(ct)) + var cu C.PantoUsage + has := false + if u != nil { + cu = cUsage(*u) + has = true + } + if C.panto_conversation_add_assistant_text(c.ptr, ct, C.bool(has), cu) != C.PANTO_OK { + return lastError() + } + return nil +} + +func (c *Conversation) AddCompactionSummary(text string) error { + ct := C.CString(text) + defer C.free(unsafe.Pointer(ct)) + if C.panto_conversation_add_compaction_summary(c.ptr, ct) != C.PANTO_OK { + return lastError() + } + return nil +} + +// Block is one immutable content block — text, thinking, tool use, tool +// result, or system text. Build it with the constructors below (TextBlock, +// ToolResultBlock, …) and assemble blocks into a message with UserMessage / +// AssistantMessage. This is the single content-block vocabulary used both to +// open a turn (Agent.Run) and to rebuild a persisted message for resumption +// (Conversation.AddMessage). +type Block struct { + tag ContentBlockTag + text string + toolID string + toolName string + input string + isError bool + systemMode SystemMode + thinkingSignature string + thinkingSigStyle APIStyle + thinkingSigBaseURL string + thinkingSigModel string +} + +// TextBlock is a plain text block. +func TextBlock(text string) Block { return Block{tag: BlockText, text: text} } + +// ThinkingBlock is an assistant reasoning block. signature/baseURL/model carry +// the signature provenance (see OwnedContentBlock); pass "" for signature when +// the provider emitted none, in which case style/baseURL/model are ignored. +func ThinkingBlock(text, signature string, style APIStyle, baseURL, model string) Block { + return Block{tag: BlockThinking, text: text, thinkingSignature: signature, thinkingSigStyle: style, thinkingSigBaseURL: baseURL, thinkingSigModel: model} +} + +// ToolUseBlock is an assistant tool call. +func ToolUseBlock(id, name, input string) Block { + return Block{tag: BlockToolUse, toolID: id, toolName: name, input: input} +} + +// ToolResultBlock is a user-role tool result keyed by the originating tool-use +// id. Used to resume a turn whose tool calls the embedder handled itself. +func ToolResultBlock(toolUseID, text string, isError bool) Block { + return Block{tag: BlockToolResult, toolID: toolUseID, text: text, isError: isError} +} + +// SystemBlock is a system-prompt block (append or replace mode). +func SystemBlock(text string, mode SystemMode) Block { + return Block{tag: BlockSystem, text: text, systemMode: mode} +} + +// MessageIdentity is the wire identity (provider style/baseURL/model + +// reasoning) that produced a message. Attach it to an InputMessage when +// rebuilding a conversation for resumption: it is the per-message source of +// truth for replaying thinking signatures and is preserved through compaction, +// so a kept-verbatim turn is not re-stamped with the compaction model. Set it +// from a PersistentMessage's APIStyle/BaseURL/Model/Reasoning. +type MessageIdentity struct { + APIStyle APIStyle + BaseURL string + Model string + Reasoning ReasoningEffort +} + +// InputMessage is one role-tagged message assembled from value Blocks, with +// optional usage and wire identity. Build it with UserMessage / AssistantMessage +// (or set the fields directly), then pass it to Agent.Run (user role) or +// Conversation.AddMessage (any role, for replay). +type InputMessage struct { + Role MessageRole + Usage *Usage + Identity *MessageIdentity + Blocks []Block +} + +// UserMessage assembles a user-role message — the argument to Agent.Run. A +// plain chat turn is a single TextBlock; a turn resuming after embedder-handled +// tool calls is one or more ToolResultBlocks. +func UserMessage(blocks ...Block) InputMessage { + return InputMessage{Role: RoleUser, Blocks: blocks} +} + +// AssistantMessage assembles an assistant-role message with optional usage — +// used to rebuild a persisted turn via Conversation.AddMessage. +func AssistantMessage(usage *Usage, blocks ...Block) InputMessage { + return InputMessage{Role: RoleAssistant, Usage: usage, Blocks: blocks} +} + +// WithIdentity returns a copy of the message tagged with the given wire +// identity (see MessageIdentity). +func (m InputMessage) WithIdentity(id MessageIdentity) InputMessage { + m.Identity = &id + return m +} + +// buildCBuilder materializes an InputMessage as a transient C message builder, +// pushing each block in order and attaching identity if present. The returned +// builder is owned by the caller and must be consumed by exactly one of +// panto_agent_run / panto_conversation_add_message (both of which destroy it). +// On error it is destroyed here and nil is returned. +func buildCBuilder(msg InputMessage) (*C.PantoMessageBuilder, error) { + p := C.panto_message_builder_create(C.PantoMessageRole(msg.Role)) + if p == nil { + return nil, lastError() + } + committed := false + defer func() { + if !committed { + C.panto_message_builder_destroy(p) + } + }() + if msg.Identity != nil { + cb := C.CString(msg.Identity.BaseURL) + defer C.free(unsafe.Pointer(cb)) + cm := C.CString(msg.Identity.Model) + defer C.free(unsafe.Pointer(cm)) + if C.panto_message_builder_set_identity(p, C.PantoAPIStyle(msg.Identity.APIStyle), cb, cm, C.PantoReasoningEffort(msg.Identity.Reasoning)) != C.PANTO_OK { + return nil, lastError() + } + } + for _, b := range msg.Blocks { + if err := pushBlock(p, b); err != nil { + return nil, err + } + } + committed = true + return p, nil +} + +func pushBlock(p *C.PantoMessageBuilder, b Block) error { + switch b.tag { + case BlockText: + ct := C.CString(b.text) + defer C.free(unsafe.Pointer(ct)) + if C.panto_message_builder_add_text(p, ct) != C.PANTO_OK { + return lastError() + } + case BlockThinking: + ct := C.CString(b.text) + defer C.free(unsafe.Pointer(ct)) + cs := C.CString(b.thinkingSignature) + defer C.free(unsafe.Pointer(cs)) + cb := C.CString(b.thinkingSigBaseURL) + defer C.free(unsafe.Pointer(cb)) + cm := C.CString(b.thinkingSigModel) + defer C.free(unsafe.Pointer(cm)) + if C.panto_message_builder_add_thinking(p, ct, cs, C.PantoAPIStyle(b.thinkingSigStyle), cb, cm) != C.PANTO_OK { + return lastError() + } + case BlockToolUse: + cid := C.CString(b.toolID) + defer C.free(unsafe.Pointer(cid)) + cn := C.CString(b.toolName) + defer C.free(unsafe.Pointer(cn)) + ci := C.CString(b.input) + defer C.free(unsafe.Pointer(ci)) + if C.panto_message_builder_add_tool_use(p, cid, cn, ci) != C.PANTO_OK { + return lastError() + } + case BlockToolResult: + cid := C.CString(b.toolID) + defer C.free(unsafe.Pointer(cid)) + ct := C.CString(b.text) + defer C.free(unsafe.Pointer(ct)) + if C.panto_message_builder_add_tool_result(p, cid, ct, C.bool(b.isError)) != C.PANTO_OK { + return lastError() + } + case BlockSystem: + ct := C.CString(b.text) + defer C.free(unsafe.Pointer(ct)) + if C.panto_message_builder_add_system(p, ct, C.PantoSystemMode(b.systemMode)) != C.PANTO_OK { + return lastError() + } + default: + return fmt.Errorf("unknown content block tag %d", int(b.tag)) + } + return nil +} + +// AddMessage commits msg as one message of msg.Role (with its optional usage +// and identity) — the block-level path for rebuilding a persisted conversation +// containing tool calls or thinking that the text-only AddUserText/ +// AddAssistantText cannot. +func (c *Conversation) AddMessage(msg InputMessage) error { + b, err := buildCBuilder(msg) + if err != nil { + return err + } + var cu C.PantoUsage + has := false + if msg.Usage != nil { + cu = cUsage(*msg.Usage) + has = true + } + // panto_conversation_add_message consumes (destroys) the builder on every + // outcome, so there is nothing to free here afterwards. + if C.panto_conversation_add_message(c.ptr, b, C.bool(has), cu) != C.PANTO_OK { + return lastError() + } + return nil +} + +type Message struct { + conv *Conversation + index uint +} + +func (m Message) Role() MessageRole { + return MessageRole(C.panto_conversation_message_role(m.conv.ptr, C.size_t(m.index))) +} + +func (m Message) Usage() *Usage { + if !bool(C.panto_conversation_message_has_usage(m.conv.ptr, C.size_t(m.index))) { + return nil + } + u := usage(C.panto_conversation_message_usage(m.conv.ptr, C.size_t(m.index))) + return &u +} + +func (m Message) BlockCount() uint { + return uint(C.panto_conversation_message_block_count(m.conv.ptr, C.size_t(m.index))) +} + +func (m Message) Block(i uint) ContentBlock { + return ContentBlock{conv: m.conv, msg: m.index, index: i} +} + +func (m Message) Snapshot() OwnedMessage { + blocks := make([]OwnedContentBlock, m.BlockCount()) + for i := range blocks { + blocks[i] = m.Block(uint(i)).Snapshot() + } + return OwnedMessage{Role: m.Role(), Usage: m.Usage(), Content: blocks} +} + +type ContentBlock struct { + conv *Conversation + msg, index uint +} + +func (b ContentBlock) Tag() ContentBlockTag { + return ContentBlockTag(C.panto_conversation_block_tag(b.conv.ptr, C.size_t(b.msg), C.size_t(b.index))) +} + +func (b ContentBlock) Text() string { + return goSlice(C.panto_conversation_block_text(b.conv.ptr, C.size_t(b.msg), C.size_t(b.index))) +} + +func (b ContentBlock) ToolID() string { + return goSlice(C.panto_conversation_block_tool_id(b.conv.ptr, C.size_t(b.msg), C.size_t(b.index))) +} + +func (b ContentBlock) ToolName() string { + return goSlice(C.panto_conversation_block_tool_name(b.conv.ptr, C.size_t(b.msg), C.size_t(b.index))) +} + +func (b ContentBlock) ToolResultIsError() bool { + return bool(C.panto_conversation_block_tool_result_is_error(b.conv.ptr, C.size_t(b.msg), C.size_t(b.index))) +} + +func (b ContentBlock) SystemMode() SystemMode { + return SystemMode(C.panto_conversation_block_system_mode(b.conv.ptr, C.size_t(b.msg), C.size_t(b.index))) +} + +func (b ContentBlock) Snapshot() OwnedContentBlock { + return OwnedContentBlock{ + Tag: b.Tag(), + Text: b.Text(), + ToolID: b.ToolID(), + ToolName: b.ToolName(), + IsError: b.ToolResultIsError(), + SystemMode: b.SystemMode(), + } +} + +func NewAgent(cfg Config) (*Agent, error) { + return NewAgentWithSession(cfg, nil, nil) +} + +func NewAgentWithSession(cfg Config, session *Session, conv *Conversation) (*Agent, error) { + ccfg, strings, err := cfg.c() + if err != nil { + 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 + conv.owned = false + runtime.SetFinalizer(conv, nil) + } + var out *C.PantoAgent + if C.panto_agent_create(&ccfg, csession, cconv, &out) != C.PANTO_OK { + return nil, lastError() + } + a := &Agent{ptr: out, retainedSession: session, retainedConv: conv} + runtime.SetFinalizer(a, (*Agent).Close) + return a, nil +} + +func (a *Agent) Close() { + if a != nil && a.ptr != nil { + 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 { + return &Conversation{ptr: C.panto_agent_conversation(a.ptr), owned: false} +} + +func (a *Agent) SetConfig(cfg Config) error { + ccfg, strings, err := cfg.c() + if err != nil { + return err + } + defer strings.free() + if C.panto_agent_set_config(a.ptr, &ccfg) != C.PANTO_OK { + return lastError() + } + return nil +} + +func (a *Agent) Compact(overrideSystemPrompt, extraInstructions *string) (CompactionResult, error) { + var co, ce *C.char + if overrideSystemPrompt != nil { + co = C.CString(*overrideSystemPrompt) + defer C.free(unsafe.Pointer(co)) + } + if extraInstructions != nil { + ce = C.CString(*extraInstructions) + defer C.free(unsafe.Pointer(ce)) + } + var out C.PantoCompactionResult + if C.panto_agent_compact(a.ptr, co, ce, &out) != C.PANTO_OK { + return CompactionResult{}, lastError() + } + return CompactionResult{ + Compacted: bool(out.compacted), + KeptTurns: uint(out.kept_turns), + SummarizedMessages: uint(out.summarized_messages), + }, nil +} + +// Run opens a turn from a user-role message and returns a resumable Stream. +// Build the message with UserMessage: a single TextBlock for a plain prompt, +// or ToolResultBlocks to resume a turn whose tool calls the embedder handled +// itself after breaking out of a prior stream. The message must be user-role. +func (a *Agent) Run(msg InputMessage) (*Stream, error) { + if msg.Role != RoleUser { + return nil, fmt.Errorf("Run requires a user-role message, got role %d", int(msg.Role)) + } + b, err := buildCBuilder(msg) + if err != nil { + return nil, err + } + // panto_agent_run consumes (destroys) the builder on every outcome. + var out *C.PantoStream + if C.panto_agent_run(a.ptr, b, &out) != C.PANTO_OK { + return nil, lastError() + } + s := &Stream{ptr: out} + runtime.SetFinalizer(s, (*Stream).Close) + return s, nil +} + +func (a *Agent) AddSystemMessage(text string) error { + ct := C.CString(text) + defer C.free(unsafe.Pointer(ct)) + if C.panto_agent_add_system_message(a.ptr, ct) != C.PANTO_OK { + return lastError() + } + return nil +} + +func (a *Agent) SetSystemPrompt(text string) error { + ct := C.CString(text) + defer C.free(unsafe.Pointer(ct)) + if C.panto_agent_set_system_prompt(a.ptr, ct) != C.PANTO_OK { + return lastError() + } + return nil +} + +func (s *Stream) Close() { + if s != nil && s.ptr != nil { + C.panto_stream_destroy(s.ptr) + s.ptr = nil + } +} + +// Reopen resets a failed stream back to its turn-open boundary so the caller +// can resume Next() after changing the agent config (e.g. catch a terminal +// bad-request, swap the provider config via Agent.SetConfig, and retry the +// SAME turn without re-appending the user message). It is valid only on a +// stream whose Next() has surfaced a terminal error; otherwise it returns an +// error. On success the stream's local error/done state is cleared so the +// iteration can continue. +func (s *Stream) Reopen() error { + if s == nil || s.ptr == nil { + return fmt.Errorf("reopen on a closed stream") + } + if C.panto_stream_reopen(s.ptr) != C.PANTO_OK { + return lastError() + } + s.err = nil + s.done = false + return nil +} + +func (s *Stream) Next() (Event, bool, error) { + if s.err != nil { + return nil, false, s.err + } + if s.done { + return nil, false, nil + } + var cev C.PantoEvent + status := C.panto_stream_next(s.ptr, &cev) + switch status { + case C.PANTO_NEXT_EVENT: + defer C.panto_event_free(&cev) + ev := marshalEvent(&cev) + if ev.EventType() == EventTurnComplete { + s.done = true + } + return ev, true, nil + case C.PANTO_NEXT_DONE: + s.done = true + return nil, false, nil + case C.PANTO_NEXT_ERROR: + s.err = lastError() + return nil, false, s.err + default: + s.err = fmt.Errorf("unknown panto next status %d", int(status)) + return nil, false, s.err + } +} +func (s *Stream) Err() error { return s.err } +func (s *Stream) Iter() iter.Seq[Event] { + return func(yield func(Event) bool) { + for { + ev, ok, err := s.Next() + if err != nil || !ok { + return + } + if !yield(ev) { + return + } + if ev.EventType() == EventTurnComplete { + return + } + } + } +} + +func (s *Stream) Chan() <-chan Event { + ch := make(chan Event) + go func() { + defer close(ch) + for ev := range s.Iter() { + ch <- ev + } + }() + return ch +} + +func (cfg Config) c() (C.PantoConfig, cStrings, error) { + var cs cStrings + provider, pstrings, err := cfg.Provider.c() + cs = append(cs, pstrings...) + if err != nil { + cs.free() + return C.PantoConfig{}, nil, err + } + out := C.PantoConfig{provider: provider} + out.compaction.keep_verbatim = C.uint32_t(cfg.Compaction.KeepVerbatim) + if cfg.Compaction.Model != nil { + m, ms, err := cfg.Compaction.Model.c() + cs = append(cs, ms...) + if err != nil { + cs.free() + return C.PantoConfig{}, nil, err + } + out.compaction.has_model = true + out.compaction.model = m + } + if cfg.Compaction.CompactionPrompt != nil { + c := C.CString(*cfg.Compaction.CompactionPrompt) + cs = append(cs, c) + out.compaction.has_compaction_prompt = true + out.compaction.compaction_prompt = c + } + out.retry.max_attempts = C.size_t(cfg.Retry.MaxAttempts) + out.retry.initial_delay_ms = C.uint64_t(cfg.Retry.InitialDelayMS) + out.retry.max_delay_ms = C.uint64_t(cfg.Retry.MaxDelayMS) + out.retry.multiplier = C.double(cfg.Retry.Multiplier) + out.retry.jitter = C.bool(cfg.Retry.Jitter) + return out, cs, nil +} + +func (p ProviderConfig) c() (C.PantoProviderConfig, cStrings, error) { + var out C.PantoProviderConfig + var cs cStrings + n := 0 + if p.OpenAIChat != nil { + n++ + } + if p.AnthropicMessages != nil { + n++ + } + if p.OpenAIResponses != nil { + n++ + } + if n != 1 { + return out, nil, errors.New("exactly one provider config must be set") + } + if p.OpenAIChat != nil { + out.tag = C.PANTO_OPENAI_CHAT + setOpenAI(&out, p.OpenAIChat, &cs) + return out, cs, nil + } + if p.OpenAIResponses != nil { + out.tag = C.PANTO_OPENAI_RESPONSES + setOpenAIResponses(&out, p.OpenAIResponses, &cs) + return out, cs, nil + } + out.tag = C.PANTO_ANTHROPIC_MESSAGES + setAnthropic(&out, p.AnthropicMessages, &cs) + return out, cs, nil +} + +func setOpenAI(out *C.PantoProviderConfig, cfg *OpenAIChatConfig, cs *cStrings) { + apiKey, baseURL, model := C.CString(cfg.APIKey), C.CString(cfg.BaseURL), C.CString(cfg.Model) + *cs = append(*cs, apiKey, baseURL, model) + c := (*C.PantoOpenAIChatConfig)(unsafe.Pointer(&out.data[0])) + c.api_key = apiKey + c.base_url = baseURL + c.model = model + c.reasoning = C.PantoReasoningEffort(cfg.Reasoning) + c.max_tokens = C.uint32_t(cfg.MaxTokens) +} + +func setOpenAIResponses(out *C.PantoProviderConfig, cfg *OpenAIResponsesConfig, cs *cStrings) { + apiKey, baseURL, model := C.CString(cfg.APIKey), C.CString(cfg.BaseURL), C.CString(cfg.Model) + *cs = append(*cs, apiKey, baseURL, model) + c := (*C.PantoOpenAIResponsesConfig)(unsafe.Pointer(&out.data[0])) + c.api_key = apiKey + c.base_url = baseURL + c.model = model + c.reasoning = C.PantoReasoningEffort(cfg.Reasoning) + c.max_tokens = C.uint32_t(cfg.MaxTokens) +} + +func setAnthropic(out *C.PantoProviderConfig, cfg *AnthropicMessagesConfig, cs *cStrings) { + apiKey, baseURL, model, ver := C.CString(cfg.APIKey), C.CString(cfg.BaseURL), C.CString(cfg.Model), C.CString(cfg.APIVersion) + *cs = append(*cs, apiKey, baseURL, model, ver) + c := (*C.PantoAnthropicMessagesConfig)(unsafe.Pointer(&out.data[0])) + c.api_key = apiKey + c.base_url = baseURL + c.model = model + c.api_version = ver + c.max_tokens = C.uint32_t(cfg.MaxTokens) + c.thinking = C.PantoThinking(cfg.Thinking) + c.effort = C.PantoEffort(cfg.Effort) + c.thinking_interleaved = C.bool(cfg.ThinkingInterleaved) + if cfg.ThinkingBudgetTokens != nil { + c.has_thinking_budget_tokens = true + c.thinking_budget_tokens = C.uint32_t(*cfg.ThinkingBudgetTokens) + } +} + +func marshalEvent(cev *C.PantoEvent) Event { + switch cev.tag { + case C.PANTO_EVENT_MESSAGE_START: + return MessageStartEvent{Role: MessageRole(*(*C.PantoMessageRole)(unsafe.Pointer(&cev.data[0])))} + case C.PANTO_EVENT_BLOCK_START: + b := (*C.PantoBlockStart)(unsafe.Pointer(&cev.data[0])) + return BlockStartEvent{BlockType: ContentBlockTag(b.block_type), Index: uint(b.index)} + case C.PANTO_EVENT_TOOL_DETAILS: + t := (*C.PantoToolDetails)(unsafe.Pointer(&cev.data[0])) + return ToolDetailsEvent{Index: uint(t.index), ID: goSlice(t.id), Name: goSlice(t.name)} + case C.PANTO_EVENT_CONTENT_DELTA: + d := (*C.PantoContentDelta)(unsafe.Pointer(&cev.data[0])) + return ContentDeltaEvent{Index: uint(d.index), Delta: goSlice(d.delta)} + case C.PANTO_EVENT_BLOCK_COMPLETE: + b := (*C.PantoBlockComplete)(unsafe.Pointer(&cev.data[0])) + return BlockCompleteEvent{Index: uint(b.index), BlockType: ContentBlockTag(b.block_type)} + case C.PANTO_EVENT_MESSAGE_COMPLETE: + m := (*C.PantoMessageComplete)(unsafe.Pointer(&cev.data[0])) + ev := MessageCompleteEvent{ + Role: MessageRole(m.role), + MessageIndex: uint(m.message_index), + BlockCount: uint(m.block_count), + } + if m.has_usage { + u := usage(m.usage) + ev.Usage = &u + } + return ev + case C.PANTO_EVENT_PROVIDER_RETRY: + r := (*C.PantoProviderRetry)(unsafe.Pointer(&cev.data[0])) + ev := ProviderRetryEvent{ + Attempt: uint(r.attempt), + MaxAttempts: uint(r.max_attempts), + DelayMS: uint64(r.delay_ms), + ErrorName: goSlice(r.error_name), + Message: goSlice(r.message), + Compaction: bool(r.compaction), + } + if r.has_status_code { + v := uint16(r.status_code) + ev.StatusCode = &v + } + if r.has_retry_after_ms { + v := uint64(r.retry_after_ms) + ev.RetryAfterMS = &v + } + return ev + case C.PANTO_EVENT_TOOL_DISPATCH_START: + t := (*C.PantoToolDispatchStart)(unsafe.Pointer(&cev.data[0])) + return ToolDispatchStartEvent{Count: uint(t.count)} + case C.PANTO_EVENT_TOOL_DISPATCH_RESULT: + t := (*C.PantoToolDispatchComplete)(unsafe.Pointer(&cev.data[0])) + return ToolDispatchResultEvent{MessageIndex: uint(t.message_index)} + case C.PANTO_EVENT_TOOL_DISPATCH_COMPLETE: + t := (*C.PantoToolDispatchComplete)(unsafe.Pointer(&cev.data[0])) + return ToolDispatchCompleteEvent{MessageIndex: uint(t.message_index)} + case C.PANTO_EVENT_TURN_COMPLETE: + return TurnCompleteEvent{} + } + return TurnCompleteEvent{} +} + +func goSlice(s C.PantoSlice) string { + if s.ptr == nil || s.len == 0 { + return "" + } + return C.GoStringN((*C.char)(unsafe.Pointer(s.ptr)), C.int(s.len)) +} + +func usage(u C.PantoUsage) Usage { + return Usage{ + Input: uint64(u.input), + Output: uint64(u.output), + CacheRead: uint64(u.cache_read), + CacheWrite: uint64(u.cache_write), + Reasoning: uint64(u.reasoning), + } +} + +func cUsage(u Usage) C.PantoUsage { + return C.PantoUsage{ + input: C.uint64_t(u.Input), + output: C.uint64_t(u.Output), + cache_read: C.uint64_t(u.CacheRead), + cache_write: C.uint64_t(u.CacheWrite), + reasoning: C.uint64_t(u.Reasoning), + } +} + +func sessionInfo(i C.PantoSessionInfo) SessionInfo { + return SessionInfo{ + ID: goSlice(i.id), + Created: goSlice(i.created), + Modified: goSlice(i.modified), + LastUserMessage: goSlice(i.last_user_message), + BaseURL: goSlice(i.base_url), + Model: goSlice(i.model), + MessageCount: uint(i.message_count), + APIStyle: APIStyle(i.api_style), + Reasoning: ReasoningEffort(i.reasoning), + } +} + +// Tool callback support. +type ( + ToolDecl struct{ Name, Description, SchemaJSON string } + ResultPart struct { + Text string + // Media carries raw bytes, not base64. libpanto detects/resizes/encodes + // supported images and PDFs for the provider; MediaType is an optional + // MIME hint. + MediaType string + Media []byte + } + ResultParts []ResultPart + ToolFunc func(input string) (ResultParts, error) + Tool struct { + Decl ToolDecl + Invoke ToolFunc + } +) + +var ( + callbackHandles = map[uintptr]any{} + nextCallbackHandle uintptr = 1 +) + +func saveCallback(v any) unsafe.Pointer { + h := nextCallbackHandle + nextCallbackHandle++ + callbackHandles[h] = v + return unsafe.Pointer(h) +} +func dropCallback(p unsafe.Pointer) { delete(callbackHandles, uintptr(p)) } + +//export pantoGoToolInvoke +func pantoGoToolInvoke(ctx unsafe.Pointer, input C.PantoSlice, out *C.PantoResultParts) C.PantoStatus { + t := callbackHandles[uintptr(ctx)].(Tool) + parts, err := t.Invoke(goSlice(input)) + if err != nil { + return C.PANTO_ERROR + } + *out = makeCResultParts(parts) + return C.PANTO_OK +} + +//export pantoGoCallbackDestroy +func pantoGoCallbackDestroy(ctx unsafe.Pointer) { dropCallback(ctx) } + +// RegisterTool registers a single tool. Note: libpanto invokes each tool call +// on its own thread, so Invoke may run concurrently — guard any shared state. +// +// TODO: back all Go tool registrations with one process-global Zig ToolSource so +// libpanto delivers the whole turn's batch on a single thread and we fan out onto +// goroutines instead of libpanto threads. Pure efficiency (cheaper than N pthreads +// per turn); the public API stays exactly this. Punted as not worth the work yet. +func (a *Agent) RegisterTool(t Tool) error { + ctx := saveCallback(t) + d, free := cToolDecl(t.Decl) + defer free() + if C.panto_agent_register_tool(a.ptr, d, ctx, (C.PantoToolInvokeFn)(C.pantoGoToolInvoke), (C.PantoToolDestroyFn)(C.pantoGoCallbackDestroy)) != C.PANTO_OK { + dropCallback(ctx) + return lastError() + } + return nil +} + +func cToolDecl(d ToolDecl) (C.PantoToolDecl, func()) { + n := cSlice(d.Name) + desc := cSlice(d.Description) + schema := cSlice(d.SchemaJSON) + return C.PantoToolDecl{name: n, description: desc, schema_json: schema}, func() { + C.free(unsafe.Pointer(n.ptr)) + C.free(unsafe.Pointer(desc.ptr)) + C.free(unsafe.Pointer(schema.ptr)) + } +} + +func cSlice(s string) C.PantoSlice { + cs := C.CString(s) + return C.PantoSlice{ptr: (*C.uint8_t)(unsafe.Pointer(cs)), len: C.size_t(len(s))} +} + +func makeCResultParts(parts ResultParts) C.PantoResultParts { + if len(parts) == 0 { + return C.PantoResultParts{} + } + arr := (*C.PantoResultPart)(C.calloc(C.size_t(len(parts)), C.size_t(unsafe.Sizeof(C.PantoResultPart{})))) + xs := unsafe.Slice(arr, len(parts)) + for i, p := range parts { + if p.Media == nil { + cs := cSlice(p.Text) + xs[i].tag = C.PANTO_RESULT_TEXT + (*C.PantoSlice)(unsafe.Pointer(&xs[i].data[0])).ptr = cs.ptr + (*C.PantoSlice)(unsafe.Pointer(&xs[i].data[0])).len = cs.len + continue + } + + xs[i].tag = C.PANTO_RESULT_MEDIA + m := (*C.PantoMediaPart)(unsafe.Pointer(&xs[i].data[0])) + m.data = C.PantoSlice{ptr: (*C.uint8_t)(C.CBytes(p.Media)), len: C.size_t(len(p.Media))} + if p.MediaType != "" { + m.media_type = cSlice(p.MediaType) + m.has_media_type = C.bool(true) + } + } + return C.PantoResultParts{ptr: arr, len: C.size_t(len(parts))} +} + +func NewGoSessionStore(impl SessionStore) (*GoSessionStore, error) { + ctx := saveCallback(impl) + var out *C.PantoSessionStore + if C.panto_go_session_store_create(ctx, &out) != C.PANTO_OK { + dropCallback(ctx) + return nil, lastError() + } + s := &GoSessionStore{ptr: out, impl: impl} + runtime.SetFinalizer(s, (*GoSessionStore).Close) + return s, nil +} +func (s *GoSessionStore) cptr() *C.PantoSessionStore { return s.ptr } +func (s *GoSessionStore) Create() (*Session, error) { return CreateSession(s) } +func (s *GoSessionStore) List() ([]SessionInfo, error) { return ListSessions(s) } +func (s *GoSessionStore) Resolve(id string) (*Session, bool, error) { return ResolveSession(s, id) } +func (s *GoSessionStore) Latest() (*Session, bool, error) { return LatestSession(s) } +func (s *GoSessionStore) Load(id string) (*Conversation, bool, error) { return s.impl.Load(id) } +func (s *GoSessionStore) AppendMessages(sessionID string, messages []PersistentMessage) error { + return s.impl.AppendMessages(sessionID, messages) +} + +func (s *GoSessionStore) Close() { + if s != nil && s.ptr != nil { + C.panto_session_store_destroy(s.ptr) + s.ptr = nil + } +} + +//export pantoGoStoreCreate +func pantoGoStoreCreate(ctx unsafe.Pointer, out **C.PantoSession) C.PantoStatus { + impl := callbackHandles[uintptr(ctx)].(SessionStore) + s, err := impl.Create() + if err != nil { + return C.PANTO_ERROR + } + *out = s.ptr + runtime.SetFinalizer(s, nil) + return C.PANTO_OK +} + +//export pantoGoStoreList +func pantoGoStoreList(ctx unsafe.Pointer, out *C.PantoSessionInfoList) C.PantoStatus { + impl := callbackHandles[uintptr(ctx)].(SessionStore) + infos, err := impl.List() + if err != nil { + return C.PANTO_ERROR + } + if len(infos) == 0 { + *out = C.PantoSessionInfoList{} + return C.PANTO_OK + } + arr := (*C.PantoSessionInfo)(C.calloc(C.size_t(len(infos)), C.size_t(unsafe.Sizeof(C.PantoSessionInfo{})))) + xs := unsafe.Slice(arr, len(infos)) + for i, info := range infos { + xs[i] = cSessionInfo(info) + } + *out = C.PantoSessionInfoList{ptr: arr, len: C.size_t(len(infos))} + return C.PANTO_OK +} + +//export pantoGoStoreFreeSessionInfos +func pantoGoStoreFreeSessionInfos(ctx unsafe.Pointer, infos C.PantoSessionInfoList) { + _ = ctx + freeCSessionInfoList(infos) +} + +//export pantoGoStoreResolve +func pantoGoStoreResolve(ctx unsafe.Pointer, id C.PantoSlice, out *C.PantoSessionResult) C.PantoStatus { + impl := callbackHandles[uintptr(ctx)].(SessionStore) + s, ok, err := impl.Resolve(goSlice(id)) + if err != nil { + return C.PANTO_ERROR + } + if !ok { + *out = C.PantoSessionResult{} + return C.PANTO_OK + } + *out = C.PantoSessionResult{found: true, session: s.ptr} + runtime.SetFinalizer(s, nil) + return C.PANTO_OK +} + +//export pantoGoStoreLatest +func pantoGoStoreLatest(ctx unsafe.Pointer, out *C.PantoSessionResult) C.PantoStatus { + impl := callbackHandles[uintptr(ctx)].(SessionStore) + s, ok, err := impl.Latest() + if err != nil { + return C.PANTO_ERROR + } + if !ok { + *out = C.PantoSessionResult{} + return C.PANTO_OK + } + *out = C.PantoSessionResult{found: true, session: s.ptr} + runtime.SetFinalizer(s, nil) + return C.PANTO_OK +} + +//export pantoGoStoreLoad +func pantoGoStoreLoad(ctx unsafe.Pointer, id C.PantoSlice, out **C.PantoConversation) C.PantoStatus { + impl := callbackHandles[uintptr(ctx)].(SessionStore) + c, ok, err := impl.Load(goSlice(id)) + if err != nil { + return C.PANTO_ERROR + } + if !ok { + return C.PANTO_ERROR + } + *out = c.ptr + c.owned = false + runtime.SetFinalizer(c, nil) + return C.PANTO_OK +} + +//export pantoGoStoreAppendMessages +func pantoGoStoreAppendMessages(ctx unsafe.Pointer, sessionID C.PantoSlice, messages *C.PantoPersistentMessage, n C.size_t) C.PantoStatus { + impl := callbackHandles[uintptr(ctx)].(SessionStore) + xs := unsafe.Slice(messages, int(n)) + out := make([]PersistentMessage, int(n)) + for i, m := range xs { + var u *Usage + if m.has_usage { + uu := usage(m.usage) + u = &uu + } + out[i] = PersistentMessage{ + Role: MessageRole(m.role), + Usage: u, + Metadata: goSlice(m.metadata), + Content: goOwnedBlocks(m.content, m.content_len), + APIStyle: APIStyle(m.api_style), + BaseURL: goSlice(m.base_url), + Model: goSlice(m.model), + Reasoning: ReasoningEffort(m.reasoning), + } + } + if err := impl.AppendMessages(goSlice(sessionID), out); err != nil { + return C.PANTO_ERROR + } + return C.PANTO_OK +} + +func goOwnedBlocks(ptr *C.PantoOwnedContentBlock, n C.size_t) []OwnedContentBlock { + if ptr == nil || n == 0 { + return nil + } + xs := unsafe.Slice(ptr, int(n)) + out := make([]OwnedContentBlock, int(n)) + for i, b := range xs { + out[i] = OwnedContentBlock{ + Tag: ContentBlockTag(b.tag), + Text: goSlice(b.text), + ToolID: goSlice(b.tool_id), + ToolName: goSlice(b.tool_name), + IsError: bool(b.is_error), + SystemMode: SystemMode(b.system_mode), + ThinkingSignature: goSlice(b.thinking_signature), + ThinkingSignatureStyle: APIStyle(b.thinking_signature_api_style), + ThinkingSignatureBaseURL: goSlice(b.thinking_signature_base_url), + ThinkingSignatureModel: goSlice(b.thinking_signature_model), + } + } + return out +} + +func cSessionInfo(i SessionInfo) C.PantoSessionInfo { + return C.PantoSessionInfo{ + id: cSlice(i.ID), + created: cSlice(i.Created), + modified: cSlice(i.Modified), + last_user_message: cSlice(i.LastUserMessage), + base_url: cSlice(i.BaseURL), + model: cSlice(i.Model), + message_count: C.size_t(i.MessageCount), + api_style: C.PantoAPIStyle(i.APIStyle), + reasoning: C.PantoReasoningEffort(i.Reasoning), + } +} + +func freeCSessionInfo(i C.PantoSessionInfo) { + C.free(unsafe.Pointer(i.id.ptr)) + C.free(unsafe.Pointer(i.created.ptr)) + C.free(unsafe.Pointer(i.modified.ptr)) + C.free(unsafe.Pointer(i.last_user_message.ptr)) + C.free(unsafe.Pointer(i.base_url.ptr)) + C.free(unsafe.Pointer(i.model.ptr)) +} + +func freeCSessionInfoList(l C.PantoSessionInfoList) { + if l.ptr == nil { + return + } + xs := unsafe.Slice(l.ptr, int(l.len)) + for _, i := range xs { + freeCSessionInfo(i) + } + C.free(unsafe.Pointer(l.ptr)) +} diff --git a/libpanto-go/panto/panto_test.go b/libpanto-go/panto/panto_test.go new file mode 100644 index 0000000..1270ead --- /dev/null +++ b/libpanto-go/panto/panto_test.go @@ -0,0 +1,213 @@ +package panto + +import "testing" + +// memStore is a minimal custom SessionStore that mints its sessions via +// NewSession (gap #1) rather than dispatching back into the vtable. +type memStore struct { + self *GoSessionStore + created bool +} + +func (m *memStore) Create() (*Session, error) { + m.created = true + return NewSession(m.self, SessionInfo{ID: "sess-1"}) +} +func (m *memStore) List() ([]SessionInfo, error) { return nil, nil } +func (m *memStore) Resolve(id string) (*Session, bool, error) { + if id != "sess-1" { + return nil, false, nil + } + s, err := NewSession(m.self, SessionInfo{ID: id}) + return s, err == nil, err +} +func (m *memStore) Latest() (*Session, bool, error) { return m.Resolve("sess-1") } +func (m *memStore) Load(id string) (*Conversation, bool, error) { return nil, false, nil } +func (m *memStore) AppendMessages(string, []PersistentMessage) error { + return nil +} + +// Gap #1: a Go store can mint and return a *Session from its own Create() +// without recursing back into the vtable. +func TestCustomStoreMintsSession(t *testing.T) { + Init() + defer Deinit() + + impl := &memStore{} + gss, err := NewGoSessionStore(impl) + if err != nil { + t.Fatalf("NewGoSessionStore: %v", err) + } + defer gss.Close() + impl.self = gss + + sess, err := gss.Create() + if err != nil { + t.Fatalf("Create: %v", err) + } + if sess == nil || sess.ptr == nil { + t.Fatal("Create returned nil session") + } + if !impl.created { + t.Fatal("custom Create() was not invoked") + } + + got, ok, err := gss.Resolve("sess-1") + if err != nil || !ok || got == nil { + t.Fatalf("Resolve: got=%v ok=%v err=%v", got, ok, err) + } +} + +// Gap #3: rebuild a conversation containing tool_use / tool_result / thinking +// via the value-block API, then read the blocks back losslessly. +func TestMessageBuilderRoundTrip(t *testing.T) { + Init() + defer Deinit() + + conv, err := NewConversation() + if err != nil { + t.Fatalf("NewConversation: %v", err) + } + defer conv.Close() + + if err := conv.AddMessage(UserMessage( + ToolResultBlock("call-1", "result text", false), + )); err != nil { + t.Fatalf("AddMessage(user): %v", err) + } + + if err := conv.AddMessage(AssistantMessage(&Usage{Input: 10, Output: 5}, + TextBlock("let me search"), + ThinkingBlock("hmm", "sig", AnthropicMessages, "https://api", "claude"), + ToolUseBlock("call-1", "search", `{"q":1}`), + )); err != nil { + t.Fatalf("AddMessage(assistant): %v", err) + } + + if got := conv.MessageCount(); got != 2 { + t.Fatalf("MessageCount = %d, want 2", got) + } + + user := conv.Message(0).Snapshot() + if user.Role != RoleUser || len(user.Content) != 1 { + t.Fatalf("user message: role=%v blocks=%d", user.Role, len(user.Content)) + } + if tr := user.Content[0]; tr.Tag != BlockToolResult || tr.ToolID != "call-1" || tr.Text != "result text" || tr.IsError { + t.Fatalf("tool_result block: %+v", tr) + } + + asst := conv.Message(1).Snapshot() + if asst.Role != RoleAssistant || len(asst.Content) != 3 { + t.Fatalf("assistant message: role=%v blocks=%d", asst.Role, len(asst.Content)) + } + if b := asst.Content[0]; b.Tag != BlockText || b.Text != "let me search" { + t.Fatalf("text block: %+v", b) + } + if b := asst.Content[1]; b.Tag != BlockThinking || b.Text != "hmm" { + t.Fatalf("thinking block: %+v", b) + } + if b := asst.Content[2]; b.Tag != BlockToolUse || b.ToolID != "call-1" || b.ToolName != "search" || b.Text != `{"q":1}` { + t.Fatalf("tool_use block: %+v", b) + } + if asst.Usage == nil || asst.Usage.Input != 10 || asst.Usage.Output != 5 { + t.Fatalf("assistant usage: %+v", asst.Usage) + } +} + +// TestMessageBuilderSetIdentity rebuilds a message with a per-message wire +// identity (and no per-block thinking origin), the resumption path that lets +// callers avoid copying the identity onto every thinking block. +func TestMessageBuilderSetIdentity(t *testing.T) { + Init() + defer Deinit() + + conv, err := NewConversation() + if err != nil { + t.Fatalf("NewConversation: %v", err) + } + defer conv.Close() + + // Thinking block with a signature but NO per-block origin; provenance is + // carried at the message level instead, via WithIdentity. + msg := AssistantMessage(&Usage{Input: 10, Output: 5}, + ThinkingBlock("reasoned", "sig", OpenAIChat, "", ""), + TextBlock("answer"), + ).WithIdentity(MessageIdentity{ + APIStyle: AnthropicMessages, + BaseURL: "https://api.anthropic.com", + Model: "claude", + Reasoning: ReasoningDefault, + }) + if err := conv.AddMessage(msg); err != nil { + t.Fatalf("AddMessage: %v", err) + } + + asst := conv.Message(0).Snapshot() + if asst.Role != RoleAssistant || len(asst.Content) != 2 { + t.Fatalf("assistant message: role=%v blocks=%d", asst.Role, len(asst.Content)) + } + if b := asst.Content[0]; b.Tag != BlockThinking || b.Text != "reasoned" { + t.Fatalf("thinking block: %+v", b) + } +} + +func testConfig() Config { + return Config{ + Provider: ProviderConfig{ + OpenAIChat: &OpenAIChatConfig{ + APIKey: "test-key", + BaseURL: "http://localhost", + Model: "test-model", + MaxTokens: 16, + }, + }, + } +} + +func TestOpenAIResponsesProviderConfigPassesThrough(t *testing.T) { + provider := ProviderConfig{ + OpenAIResponses: &OpenAIResponsesConfig{ + APIKey: "test-key", + BaseURL: "http://localhost", + Model: "test-model", + Reasoning: ReasoningMedium, + MaxTokens: 128, + }, + } + c, strings, err := provider.c() + if err != nil { + t.Fatalf("ProviderConfig.c: %v", err) + } + defer strings.free() + + if APIStyle(c.tag) != OpenAIResponses { + t.Fatalf("tag = %v, want %v", APIStyle(c.tag), OpenAIResponses) + } + + Init() + defer Deinit() + + a, err := NewAgent(Config{Provider: provider}) + if err != nil { + t.Fatalf("NewAgent: %v", err) + } + defer a.Close() +} + +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) + } +} diff --git a/libpanto-go/panto_test.go b/libpanto-go/panto_test.go deleted file mode 100644 index 1270ead..0000000 --- a/libpanto-go/panto_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package panto - -import "testing" - -// memStore is a minimal custom SessionStore that mints its sessions via -// NewSession (gap #1) rather than dispatching back into the vtable. -type memStore struct { - self *GoSessionStore - created bool -} - -func (m *memStore) Create() (*Session, error) { - m.created = true - return NewSession(m.self, SessionInfo{ID: "sess-1"}) -} -func (m *memStore) List() ([]SessionInfo, error) { return nil, nil } -func (m *memStore) Resolve(id string) (*Session, bool, error) { - if id != "sess-1" { - return nil, false, nil - } - s, err := NewSession(m.self, SessionInfo{ID: id}) - return s, err == nil, err -} -func (m *memStore) Latest() (*Session, bool, error) { return m.Resolve("sess-1") } -func (m *memStore) Load(id string) (*Conversation, bool, error) { return nil, false, nil } -func (m *memStore) AppendMessages(string, []PersistentMessage) error { - return nil -} - -// Gap #1: a Go store can mint and return a *Session from its own Create() -// without recursing back into the vtable. -func TestCustomStoreMintsSession(t *testing.T) { - Init() - defer Deinit() - - impl := &memStore{} - gss, err := NewGoSessionStore(impl) - if err != nil { - t.Fatalf("NewGoSessionStore: %v", err) - } - defer gss.Close() - impl.self = gss - - sess, err := gss.Create() - if err != nil { - t.Fatalf("Create: %v", err) - } - if sess == nil || sess.ptr == nil { - t.Fatal("Create returned nil session") - } - if !impl.created { - t.Fatal("custom Create() was not invoked") - } - - got, ok, err := gss.Resolve("sess-1") - if err != nil || !ok || got == nil { - t.Fatalf("Resolve: got=%v ok=%v err=%v", got, ok, err) - } -} - -// Gap #3: rebuild a conversation containing tool_use / tool_result / thinking -// via the value-block API, then read the blocks back losslessly. -func TestMessageBuilderRoundTrip(t *testing.T) { - Init() - defer Deinit() - - conv, err := NewConversation() - if err != nil { - t.Fatalf("NewConversation: %v", err) - } - defer conv.Close() - - if err := conv.AddMessage(UserMessage( - ToolResultBlock("call-1", "result text", false), - )); err != nil { - t.Fatalf("AddMessage(user): %v", err) - } - - if err := conv.AddMessage(AssistantMessage(&Usage{Input: 10, Output: 5}, - TextBlock("let me search"), - ThinkingBlock("hmm", "sig", AnthropicMessages, "https://api", "claude"), - ToolUseBlock("call-1", "search", `{"q":1}`), - )); err != nil { - t.Fatalf("AddMessage(assistant): %v", err) - } - - if got := conv.MessageCount(); got != 2 { - t.Fatalf("MessageCount = %d, want 2", got) - } - - user := conv.Message(0).Snapshot() - if user.Role != RoleUser || len(user.Content) != 1 { - t.Fatalf("user message: role=%v blocks=%d", user.Role, len(user.Content)) - } - if tr := user.Content[0]; tr.Tag != BlockToolResult || tr.ToolID != "call-1" || tr.Text != "result text" || tr.IsError { - t.Fatalf("tool_result block: %+v", tr) - } - - asst := conv.Message(1).Snapshot() - if asst.Role != RoleAssistant || len(asst.Content) != 3 { - t.Fatalf("assistant message: role=%v blocks=%d", asst.Role, len(asst.Content)) - } - if b := asst.Content[0]; b.Tag != BlockText || b.Text != "let me search" { - t.Fatalf("text block: %+v", b) - } - if b := asst.Content[1]; b.Tag != BlockThinking || b.Text != "hmm" { - t.Fatalf("thinking block: %+v", b) - } - if b := asst.Content[2]; b.Tag != BlockToolUse || b.ToolID != "call-1" || b.ToolName != "search" || b.Text != `{"q":1}` { - t.Fatalf("tool_use block: %+v", b) - } - if asst.Usage == nil || asst.Usage.Input != 10 || asst.Usage.Output != 5 { - t.Fatalf("assistant usage: %+v", asst.Usage) - } -} - -// TestMessageBuilderSetIdentity rebuilds a message with a per-message wire -// identity (and no per-block thinking origin), the resumption path that lets -// callers avoid copying the identity onto every thinking block. -func TestMessageBuilderSetIdentity(t *testing.T) { - Init() - defer Deinit() - - conv, err := NewConversation() - if err != nil { - t.Fatalf("NewConversation: %v", err) - } - defer conv.Close() - - // Thinking block with a signature but NO per-block origin; provenance is - // carried at the message level instead, via WithIdentity. - msg := AssistantMessage(&Usage{Input: 10, Output: 5}, - ThinkingBlock("reasoned", "sig", OpenAIChat, "", ""), - TextBlock("answer"), - ).WithIdentity(MessageIdentity{ - APIStyle: AnthropicMessages, - BaseURL: "https://api.anthropic.com", - Model: "claude", - Reasoning: ReasoningDefault, - }) - if err := conv.AddMessage(msg); err != nil { - t.Fatalf("AddMessage: %v", err) - } - - asst := conv.Message(0).Snapshot() - if asst.Role != RoleAssistant || len(asst.Content) != 2 { - t.Fatalf("assistant message: role=%v blocks=%d", asst.Role, len(asst.Content)) - } - if b := asst.Content[0]; b.Tag != BlockThinking || b.Text != "reasoned" { - t.Fatalf("thinking block: %+v", b) - } -} - -func testConfig() Config { - return Config{ - Provider: ProviderConfig{ - OpenAIChat: &OpenAIChatConfig{ - APIKey: "test-key", - BaseURL: "http://localhost", - Model: "test-model", - MaxTokens: 16, - }, - }, - } -} - -func TestOpenAIResponsesProviderConfigPassesThrough(t *testing.T) { - provider := ProviderConfig{ - OpenAIResponses: &OpenAIResponsesConfig{ - APIKey: "test-key", - BaseURL: "http://localhost", - Model: "test-model", - Reasoning: ReasoningMedium, - MaxTokens: 128, - }, - } - c, strings, err := provider.c() - if err != nil { - t.Fatalf("ProviderConfig.c: %v", err) - } - defer strings.free() - - if APIStyle(c.tag) != OpenAIResponses { - t.Fatalf("tag = %v, want %v", APIStyle(c.tag), OpenAIResponses) - } - - Init() - defer Deinit() - - a, err := NewAgent(Config{Provider: provider}) - if err != nil { - t.Fatalf("NewAgent: %v", err) - } - defer a.Close() -} - -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) - } -} -- cgit v1.3