summaryrefslogtreecommitdiff
path: root/libpanto-go
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto-go')
-rw-r--r--libpanto-go/panto.go203
-rw-r--r--libpanto-go/panto_test.go126
2 files changed, 322 insertions, 7 deletions
diff --git a/libpanto-go/panto.go b/libpanto-go/panto.go
index da3bcb1..95e0cbd 100644
--- a/libpanto-go/panto.go
+++ b/libpanto-go/panto.go
@@ -252,6 +252,13 @@ type PersistentMessage struct {
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
@@ -266,6 +273,14 @@ type OwnedContentBlock struct {
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 MessageBuilder.AddThinking —
+ // Anthropic drops a thinking block whose signature lacks a matching origin.
+ ThinkingSignature string
+ ThinkingSignatureStyle APIStyle
+ ThinkingSignatureBaseURL string
+ ThinkingSignatureModel string
}
type cStrings []*C.char
@@ -365,6 +380,37 @@ func CreateSession(store cSessionStore) (*Session, error) {
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))
@@ -495,6 +541,113 @@ func (c *Conversation) AddCompactionSummary(text string) error {
return nil
}
+// MessageBuilder accumulates content blocks for one message, then commits them
+// to a Conversation via Conversation.AddMessage. It is the block-level builder
+// for rebuilding a persisted conversation that contains tool calls or thinking
+// (the text-only AddUserText/AddAssistantText cannot). Add blocks in order,
+// then call AddMessage (which consumes the builder). Call Destroy only to
+// discard a builder you never commit.
+type MessageBuilder struct {
+ ptr *C.PantoMessageBuilder
+}
+
+func NewMessageBuilder(role MessageRole) (*MessageBuilder, error) {
+ p := C.panto_message_builder_create(C.PantoMessageRole(role))
+ if p == nil {
+ return nil, lastError()
+ }
+ b := &MessageBuilder{ptr: p}
+ runtime.SetFinalizer(b, (*MessageBuilder).Destroy)
+ return b, nil
+}
+
+func (b *MessageBuilder) AddText(text string) error {
+ ct := C.CString(text)
+ defer C.free(unsafe.Pointer(ct))
+ if C.panto_message_builder_add_text(b.ptr, ct) != C.PANTO_OK {
+ return lastError()
+ }
+ return nil
+}
+
+// AddThinking appends a thinking 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 (b *MessageBuilder) AddThinking(text, signature string, style APIStyle, baseURL, model string) error {
+ ct := C.CString(text)
+ defer C.free(unsafe.Pointer(ct))
+ cs := C.CString(signature)
+ defer C.free(unsafe.Pointer(cs))
+ cb := C.CString(baseURL)
+ defer C.free(unsafe.Pointer(cb))
+ cm := C.CString(model)
+ defer C.free(unsafe.Pointer(cm))
+ if C.panto_message_builder_add_thinking(b.ptr, ct, cs, C.PantoAPIStyle(style), cb, cm) != C.PANTO_OK {
+ return lastError()
+ }
+ return nil
+}
+
+func (b *MessageBuilder) AddToolUse(id, name, input string) error {
+ cid := C.CString(id)
+ defer C.free(unsafe.Pointer(cid))
+ cn := C.CString(name)
+ defer C.free(unsafe.Pointer(cn))
+ ci := C.CString(input)
+ defer C.free(unsafe.Pointer(ci))
+ if C.panto_message_builder_add_tool_use(b.ptr, cid, cn, ci) != C.PANTO_OK {
+ return lastError()
+ }
+ return nil
+}
+
+func (b *MessageBuilder) AddToolResult(toolUseID, text string, isError bool) error {
+ cid := C.CString(toolUseID)
+ defer C.free(unsafe.Pointer(cid))
+ ct := C.CString(text)
+ defer C.free(unsafe.Pointer(ct))
+ if C.panto_message_builder_add_tool_result(b.ptr, cid, ct, C.bool(isError)) != C.PANTO_OK {
+ return lastError()
+ }
+ return nil
+}
+
+func (b *MessageBuilder) AddSystem(text string, mode SystemMode) error {
+ ct := C.CString(text)
+ defer C.free(unsafe.Pointer(ct))
+ if C.panto_message_builder_add_system(b.ptr, ct, C.PantoSystemMode(mode)) != C.PANTO_OK {
+ return lastError()
+ }
+ return nil
+}
+
+// Destroy frees an uncommitted builder. Safe to call after AddMessage (no-op).
+func (b *MessageBuilder) Destroy() {
+ if b != nil && b.ptr != nil {
+ C.panto_message_builder_destroy(b.ptr)
+ b.ptr = nil
+ }
+}
+
+// AddMessage commits the builder's blocks as one message of the builder's role,
+// with optional assistant usage. It consumes the builder (success or failure);
+// the builder must not be used afterwards.
+func (c *Conversation) AddMessage(b *MessageBuilder, u *Usage) error {
+ var cu C.PantoUsage
+ has := false
+ if u != nil {
+ cu = cUsage(*u)
+ has = true
+ }
+ st := C.panto_conversation_add_message(c.ptr, b.ptr, C.bool(has), cu)
+ b.ptr = nil
+ runtime.SetFinalizer(b, nil)
+ if st != C.PANTO_OK {
+ return lastError()
+ }
+ return nil
+}
+
type Message struct {
conv *Conversation
index uint
@@ -1209,7 +1362,16 @@ func pantoGoStoreAppendMessages(ctx unsafe.Pointer, sessionID C.PantoSlice, mess
uu := usage(m.usage)
u = &uu
}
- out[i] = PersistentMessage{Role: MessageRole(m.role), Usage: u, Metadata: goSlice(m.metadata)}
+ 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
@@ -1217,6 +1379,29 @@ func pantoGoStoreAppendMessages(ctx unsafe.Pointer, sessionID C.PantoSlice, mess
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),
@@ -1231,18 +1416,22 @@ func cSessionInfo(i SessionInfo) C.PantoSessionInfo {
}
}
+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 {
- 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))
+ freeCSessionInfo(i)
}
C.free(unsafe.Pointer(l.ptr))
}
diff --git a/libpanto-go/panto_test.go b/libpanto-go/panto_test.go
index ef21f1e..5cfed01 100644
--- a/libpanto-go/panto_test.go
+++ b/libpanto-go/panto_test.go
@@ -2,6 +2,132 @@ 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 MessageBuilder, 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()
+
+ ub, err := NewMessageBuilder(RoleUser)
+ if err != nil {
+ t.Fatalf("NewMessageBuilder(user): %v", err)
+ }
+ if err := ub.AddToolResult("call-1", "result text", false); err != nil {
+ t.Fatalf("AddToolResult: %v", err)
+ }
+ if err := conv.AddMessage(ub, nil); err != nil {
+ t.Fatalf("AddMessage(user): %v", err)
+ }
+
+ ab, err := NewMessageBuilder(RoleAssistant)
+ if err != nil {
+ t.Fatalf("NewMessageBuilder(assistant): %v", err)
+ }
+ if err := ab.AddText("let me search"); err != nil {
+ t.Fatalf("AddText: %v", err)
+ }
+ if err := ab.AddThinking("hmm", "sig", AnthropicMessages, "https://api", "claude"); err != nil {
+ t.Fatalf("AddThinking: %v", err)
+ }
+ if err := ab.AddToolUse("call-1", "search", `{"q":1}`); err != nil {
+ t.Fatalf("AddToolUse: %v", err)
+ }
+ if err := conv.AddMessage(ab, &Usage{Input: 10, Output: 5}); 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)
+ }
+}
+
func testConfig() Config {
return Config{
Provider: ProviderConfig{