first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
// Package agentcore defines the core "leaf" data types and control flow for the
// pigo agent harness, a Go reimplementation of the pi agent loop. It is the
// foundation package that every other agent sub-package depends on and imports
// nothing from them.
//
// This file defines the Content model: a sealed interface implemented by the
// four content block kinds (text, thinking, toolCall, image). Because Go's
// encoding/json cannot dispatch to an interface based on a discriminant field,
// containers holding []Content implement custom UnmarshalJSON that peeks at the
// "type" field and decodes into the concrete struct. Mirrors pi's discriminated
// union (packages/ai/src/types.ts) as interface + type switch.
package agentcore
import (
"bytes"
"encoding/json"
"fmt"
)
// Content is a sealed interface implemented by every content block kind.
// Consumers dispatch with a type switch. The interface is sealed via the
// unexported isContent marker so no type outside this package can satisfy it.
type Content interface {
isContent()
}
// Content type discriminants, matching pi's wire format.
const (
ContentTypeText = "text"
ContentTypeThinking = "thinking"
ContentTypeToolCall = "toolCall"
ContentTypeImage = "image"
)
// TextContent is a plain text block.
type TextContent struct {
Type string `json:"type"`
Text string `json:"text"`
TextSignature string `json:"textSignature,omitempty"`
}
// ThinkingContent is a reasoning/thinking block. Never folded into text.
type ThinkingContent struct {
Type string `json:"type"`
Thinking string `json:"thinking"`
ThinkingSignature string `json:"thinkingSignature,omitempty"`
Redacted bool `json:"redacted,omitempty"`
}
// ToolCallContent is a request from the model to invoke a tool. Arguments are
// kept as raw JSON so validation (JSON Schema) and shaping happen downstream.
type ToolCallContent struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
ThoughtSignature string `json:"thoughtSignature,omitempty"`
}
// ImageContent is an image block (base64 data + mime type).
type ImageContent struct {
Type string `json:"type"`
Data string `json:"data"`
MimeType string `json:"mimeType"`
}
func (TextContent) isContent() {}
func (ThinkingContent) isContent() {}
func (ToolCallContent) isContent() {}
func (ImageContent) isContent() {}
// MarshalJSON encodes a ToolCallContent, tolerating malformed Arguments. A
// model can stream syntactically invalid tool-call JSON (a truncated or
// duplicated key, e.g. `{"todos": []{}...`); such bytes are kept verbatim in
// Arguments so schema validation can report "not valid JSON" to the model, but
// json.RawMessage.MarshalJSON rejects them, which would otherwise abort every
// downstream serialization (session persistence, provider re-serialization) and
// take the whole turn down. To keep those paths crash-free we emit invalid
// arguments as a JSON string of the raw bytes: valid JSON that round-trips the
// original text. Well-formed arguments are emitted unchanged.
func (t ToolCallContent) MarshalJSON() ([]byte, error) {
args := t.Arguments
if len(bytes.TrimSpace(args)) == 0 {
args = json.RawMessage("{}")
} else if !json.Valid(args) {
s, err := json.Marshal(string(args))
if err != nil {
return nil, fmt.Errorf("content: encode invalid tool arguments: %w", err)
}
args = s
}
// A named alias avoids recursing into this MarshalJSON.
type wire struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
ThoughtSignature string `json:"thoughtSignature,omitempty"`
}
return json.Marshal(wire{
Type: t.Type,
ID: t.ID,
Name: t.Name,
Arguments: args,
ThoughtSignature: t.ThoughtSignature,
})
}
// Constructors set the Type discriminant so callers never desync it.
// NewTextContent returns a TextContent with the type discriminant set.
func NewTextContent(text string) TextContent {
return TextContent{Type: ContentTypeText, Text: text}
}
// NewThinkingContent returns a ThinkingContent with the type discriminant set.
func NewThinkingContent(thinking string) ThinkingContent {
return ThinkingContent{Type: ContentTypeThinking, Thinking: thinking}
}
// NewToolCallContent returns a ToolCallContent with the type discriminant set.
func NewToolCallContent(id, name string, arguments json.RawMessage) ToolCallContent {
return ToolCallContent{Type: ContentTypeToolCall, ID: id, Name: name, Arguments: arguments}
}
// NewImageContent returns an ImageContent with the type discriminant set.
func NewImageContent(data, mimeType string) ImageContent {
return ImageContent{Type: ContentTypeImage, Data: data, MimeType: mimeType}
}
// decodeContent peeks at the "type" field of a JSON object and decodes it into
// the matching concrete Content struct. This is the single dispatch point used
// by every container that holds Content (messages, tool results, session
// entries, provider parsing).
func decodeContent(raw json.RawMessage) (Content, error) {
var probe struct {
Type string `json:"type"`
}
if err := json.Unmarshal(raw, &probe); err != nil {
return nil, fmt.Errorf("content: peek type: %w", err)
}
switch probe.Type {
case ContentTypeText:
var c TextContent
if err := json.Unmarshal(raw, &c); err != nil {
return nil, err
}
return c, nil
case ContentTypeThinking:
var c ThinkingContent
if err := json.Unmarshal(raw, &c); err != nil {
return nil, err
}
return c, nil
case ContentTypeToolCall:
var c ToolCallContent
if err := json.Unmarshal(raw, &c); err != nil {
return nil, err
}
return c, nil
case ContentTypeImage:
var c ImageContent
if err := json.Unmarshal(raw, &c); err != nil {
return nil, err
}
return c, nil
case "":
return nil, fmt.Errorf("content: missing type discriminant")
default:
return nil, fmt.Errorf("content: unknown type %q", probe.Type)
}
}
// ContentList is a slice of Content with discriminated JSON (un)marshalling.
// Fields typed []Content in messages use this so decoding dispatches on "type".
type ContentList []Content
// UnmarshalJSON decodes a JSON array of content blocks, dispatching each element
// on its "type" discriminant.
func (cl *ContentList) UnmarshalJSON(data []byte) error {
var raws []json.RawMessage
if err := json.Unmarshal(data, &raws); err != nil {
return err
}
out := make(ContentList, 0, len(raws))
for i, raw := range raws {
c, err := decodeContent(raw)
if err != nil {
return fmt.Errorf("content[%d]: %w", i, err)
}
out = append(out, c)
}
*cl = out
return nil
}
+217
View File
@@ -0,0 +1,217 @@
package agentcore
// AgentEvent is the sealed interface implemented by every event the loop emits.
// Consumers dispatch with a type switch, consistent with Content. pigo covers
// all 10 of pi's event types (PRD FR-24).
type AgentEvent interface {
isAgentEvent()
// EventType returns the discriminant string, useful for logging and for
// serialising events to the stream-json/stdio protocol (US-020).
EventType() string
}
// Event type discriminants.
const (
EventAgentStart = "agent_start"
EventAgentEnd = "agent_end"
EventTurnStart = "turn_start"
EventTurnEnd = "turn_end"
EventMessageStart = "message_start"
EventMessageUpdate = "message_update"
EventMessageEnd = "message_end"
EventToolExecutionStart = "tool_execution_start"
EventToolExecutionUpdate = "tool_execution_update"
EventToolExecutionEnd = "tool_execution_end"
EventCompaction = "compaction"
EventCompactionStart = "compaction_start"
EventTelemetry = "telemetry"
EventSubAgentProgress = "subagent_progress"
)
// AgentStartEvent is emitted once when a loop run begins. SessionID, when set,
// is the id of the session backing this run; it is carried in the first
// stream-json event so a caller can associate output with a session and resume
// it later (mirrors pi/Claude Code, which put a session id in the first event).
type AgentStartEvent struct {
SessionID string
}
// AgentEndEvent is emitted once when a loop run ends, carrying the messages
// newly produced during this run (the EventStream result).
type AgentEndEvent struct {
Messages []AgentMessage
}
// TurnStartEvent marks the start of a turn (a single assistant response cycle).
type TurnStartEvent struct{}
// TurnEndEvent marks the end of a turn, with the assistant message and any tool
// results produced during it.
type TurnEndEvent struct {
Message AssistantMessage
ToolResults []ToolResultMessage
}
// MessageStartEvent is emitted when a message begins streaming.
type MessageStartEvent struct {
Message AgentMessage
}
// MessageUpdateEvent is emitted for each streaming delta, carrying the current
// partial message and the raw provider-level event that produced it.
type MessageUpdateEvent struct {
Message AgentMessage
AssistantMessageEvent any
}
// MessageEndEvent is emitted when a message finishes streaming.
type MessageEndEvent struct {
Message AgentMessage
}
// ToolExecutionStartEvent is emitted before a tool runs.
type ToolExecutionStartEvent struct {
ToolCallID string
ToolName string
Args any
}
// ToolExecutionUpdateEvent carries a partial result during tool execution.
type ToolExecutionUpdateEvent struct {
ToolCallID string
ToolName string
PartialResult AgentToolResult
}
// ToolExecutionEndEvent is emitted when a tool finishes.
type ToolExecutionEndEvent struct {
ToolCallID string
ToolName string
Result AgentToolResult
IsError bool
}
// CompactionEvent is emitted when the loop compacts the context window, either
// automatically (threshold/overflow) or on an explicit /compact request. It
// carries before/after token counts and how many messages were summarized vs.
// retained. When compaction fails it is still emitted with ErrorMessage set and
// the token/count fields describing the unchanged context, so consumers can
// surface the failure without the session aborting (US-004).
type CompactionEvent struct {
// Reason is why compaction ran: "manual", "threshold", or "overflow".
Reason string
// TokensBefore is the estimated context tokens prior to compaction.
TokensBefore int
// TokensAfter is the estimated context tokens after compaction (equals
// TokensBefore when compaction failed or was a no-op).
TokensAfter int
// SummarizedCount is the number of messages folded into the summary.
SummarizedCount int
// KeptCount is the number of recent messages retained verbatim.
KeptCount int
// ErrorMessage is non-empty when compaction failed; the original context is
// preserved in that case.
ErrorMessage string
}
// CompactionStartEvent is emitted immediately before the loop runs compaction,
// so a front-end can show an in-progress "Compacting conversation…" indicator
// while the summarization request is in flight. The matching CompactionEvent is
// emitted when it completes (or fails). Reason mirrors CompactionEvent.Reason.
type CompactionStartEvent struct {
// Reason is why compaction is running: "manual", "threshold", or "overflow".
Reason string
// TokensBefore is the estimated context tokens that triggered compaction.
TokensBefore int
}
// SubAgentProgressEvent carries structured progress from a running sub-agent
// (dispatched by the task tool). It is reported at the sub-agent's tool
// execution / turn boundaries so a TUI (multi-line status panel) or headless
// mode (stderr line) can display live progress. Elapsed time is intentionally
// omitted: consumers compute it themselves (TUI from tool-start time, headless
// from when the id was first seen) to avoid emitting an event per frame.
type SubAgentProgressEvent struct {
// ToolCallID is the parent task call's tool-call id, used as the key for
// the status line.
ToolCallID string
// Description is the task call's description, for display (may be empty).
Description string
// Activity is the current activity: tool name / phase, e.g. "Editing",
// "Running bash", "Thinking".
Activity string
// Tokens is the estimated sub-agent output token count (0 = unknown).
Tokens int
}
// ToolTiming records how long one tool invocation took, keyed by tool name in
// TelemetryEvent.ToolDurationsMs. It aggregates repeated calls of the same tool
// so a summary stays compact regardless of turn count.
type ToolTiming struct {
// Count is how many times the tool was invoked over the run.
Count int
// TotalMs is the summed wall-clock duration of every invocation, in
// milliseconds.
TotalMs int64
}
// TelemetryEvent is a lightweight, additive observability summary emitted once
// at run end (just before agent_end) so scripts consuming the stream-json
// output can read structured metrics without a new dependency (no
// Prometheus/OTLP). It is purely observational: consumers that ignore it behave
// exactly as before. Metrics covered (observability — structured telemetry collection):
// - per-tool wall-clock durations (ToolDurationsMs, aggregated by tool name),
// - how many turns ran (Turns),
// - how many assistant responses were truncated by the output cap
// (TruncationCount),
// - how many times the context was compacted (CompactionCount),
// - the latest context-utilization ratio (ContextUtilization = used tokens /
// ContextWindow) and the raw numbers behind it.
type TelemetryEvent struct {
// Turns is the number of turns (turn_start events) the run executed.
Turns int
// ToolDurationsMs maps a tool name to its aggregated timing over the run.
ToolDurationsMs map[string]ToolTiming
// TruncationCount is how many assistant responses stopped with reason
// "length" (truncated by the output token cap), each triggering a resend.
TruncationCount int
// CompactionCount is how many successful context compactions occurred.
CompactionCount int
// ContextUtilization is the latest used/window ratio in [0,1], or 0 when the
// context window is unknown. Computed as ContextTokens / ContextWindow.
ContextUtilization float64
// ContextTokens is the most recently observed estimated context-token usage.
ContextTokens int
// ContextWindow is the model's total context-token budget (0 when unknown).
ContextWindow int
}
func (AgentStartEvent) isAgentEvent() {}
func (AgentEndEvent) isAgentEvent() {}
func (TurnStartEvent) isAgentEvent() {}
func (TurnEndEvent) isAgentEvent() {}
func (MessageStartEvent) isAgentEvent() {}
func (MessageUpdateEvent) isAgentEvent() {}
func (MessageEndEvent) isAgentEvent() {}
func (ToolExecutionStartEvent) isAgentEvent() {}
func (ToolExecutionUpdateEvent) isAgentEvent() {}
func (ToolExecutionEndEvent) isAgentEvent() {}
func (CompactionEvent) isAgentEvent() {}
func (CompactionStartEvent) isAgentEvent() {}
func (TelemetryEvent) isAgentEvent() {}
func (SubAgentProgressEvent) isAgentEvent() {}
func (AgentStartEvent) EventType() string { return EventAgentStart }
func (AgentEndEvent) EventType() string { return EventAgentEnd }
func (TurnStartEvent) EventType() string { return EventTurnStart }
func (TurnEndEvent) EventType() string { return EventTurnEnd }
func (MessageStartEvent) EventType() string { return EventMessageStart }
func (MessageUpdateEvent) EventType() string { return EventMessageUpdate }
func (MessageEndEvent) EventType() string { return EventMessageEnd }
func (ToolExecutionStartEvent) EventType() string { return EventToolExecutionStart }
func (ToolExecutionUpdateEvent) EventType() string { return EventToolExecutionUpdate }
func (ToolExecutionEndEvent) EventType() string { return EventToolExecutionEnd }
func (CompactionEvent) EventType() string { return EventCompaction }
func (CompactionStartEvent) EventType() string { return EventCompactionStart }
func (TelemetryEvent) EventType() string { return EventTelemetry }
func (SubAgentProgressEvent) EventType() string { return EventSubAgentProgress }
+121
View File
@@ -0,0 +1,121 @@
package agentcore
import (
"context"
"errors"
"sync"
)
// EventStream is the Go equivalent of pi's EventStream<T,R>: a producer pushes
// events onto a channel while a consumer ranges over them, and a terminal event
// yields a final result R. It replaces pi's async generator (event-stream.ts).
//
// Design (research §2.2):
// - Iteration: Events() returns <-chan T for `for ev := range s.Events()`.
// - Result: Result(ctx) blocks until the producer sets a result (or the
// stream fails/cancels). The result is NOT sent on the event channel, so a
// consumer that stops reading events can still obtain it.
// - Cancellation: the producer selects on ctx.Done() when sending, so a
// consumer that stops reading never leaks the producer goroutine.
//
// pi's isComplete/extractResult callbacks are retained as optional fields so a
// producer can let the stream detect the terminal event itself; a producer may
// instead call SetResult explicitly (more Go-idiomatic). Either path resolves
// Result exactly once.
type EventStream[T any, R any] struct {
ch chan T
// IsComplete reports whether an event is the terminal one. Optional: if
// set, Emit auto-captures the result via ExtractResult when it returns true.
IsComplete func(event T) bool
// ExtractResult derives the final result from the terminal event. Required
// when IsComplete is set.
ExtractResult func(event T) R
resultOnce sync.Once
result R
resultErr error
resultCh chan struct{} // closed once result (or resultErr) is set
}
// ErrStreamIncomplete is returned by Result when the event channel closed
// without any result being set (the producer ended abnormally without a
// terminal event).
var ErrStreamIncomplete = errors.New("agent: event stream ended without a result")
// NewEventStream constructs an EventStream with the given channel buffer size.
// A buffer of 0 gives fully synchronous back-pressure (each Emit blocks until a
// consumer receives), matching pi's sequential `await emit(...)`.
func NewEventStream[T any, R any](buffer int) *EventStream[T, R] {
if buffer < 0 {
buffer = 0
}
return &EventStream[T, R]{
ch: make(chan T, buffer),
resultCh: make(chan struct{}),
}
}
// Events returns the receive-only event channel. Ranging over it terminates
// when the producer calls Close.
func (s *EventStream[T, R]) Events() <-chan T { return s.ch }
// Emit sends an event to consumers, honoring cancellation. If ctx is cancelled
// before the event is received, Emit returns ctx.Err() and the event is
// dropped. When IsComplete is configured and reports true for the event, the
// result is captured (once) before the send.
func (s *EventStream[T, R]) Emit(ctx context.Context, event T) error {
if s.IsComplete != nil && s.IsComplete(event) && s.ExtractResult != nil {
s.SetResult(s.ExtractResult(event))
}
select {
case s.ch <- event:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// SetResult records the final result. Only the first call wins; later calls
// (including SetError) are no-ops. Safe to call before Close.
func (s *EventStream[T, R]) SetResult(result R) {
s.resultOnce.Do(func() {
s.result = result
close(s.resultCh)
})
}
// SetError records a terminal error as the stream's outcome. Only the first
// call among SetResult/SetError wins.
func (s *EventStream[T, R]) SetError(err error) {
s.resultOnce.Do(func() {
s.resultErr = err
close(s.resultCh)
})
}
// Close closes the event channel, ending consumer iteration. If no result was
// set, Result will report ErrStreamIncomplete. Call exactly once from the
// producer after the last Emit.
func (s *EventStream[T, R]) Close() {
// Ensure a waiting Result never blocks forever if the producer forgot to
// set a result.
s.resultOnce.Do(func() {
s.resultErr = ErrStreamIncomplete
close(s.resultCh)
})
close(s.ch)
}
// Result blocks until the producer sets a result/error, ctx is cancelled, or
// the stream closes without a result. It is safe to call concurrently and
// returns the same outcome on every call.
func (s *EventStream[T, R]) Result(ctx context.Context) (R, error) {
select {
case <-s.resultCh:
return s.result, s.resultErr
case <-ctx.Done():
var zero R
return zero, ctx.Err()
}
}
@@ -0,0 +1,121 @@
package agentcore
import (
"context"
"testing"
"time"
)
// TestEventStreamNormalCompletion drives a producer that emits events and sets
// a result, then verifies the consumer sees every event and Result yields the
// captured value.
func TestEventStreamNormalCompletion(t *testing.T) {
s := NewEventStream[AgentEvent, []AgentMessage](0)
want := []AgentMessage{
UserMessage{RoleField: RoleUser, Content: ContentList{NewTextContent("hi")}},
}
go func() {
ctx := context.Background()
_ = s.Emit(ctx, TurnStartEvent{})
_ = s.Emit(ctx, AgentEndEvent{Messages: want})
s.SetResult(want)
s.Close()
}()
var got int
for range s.Events() {
got++
}
if got != 2 {
t.Fatalf("want 2 events, got %d", got)
}
res, err := s.Result(context.Background())
if err != nil {
t.Fatalf("result: %v", err)
}
if len(res) != 1 || res[0].Role() != RoleUser {
t.Fatalf("result payload wrong: %+v", res)
}
}
// TestEventStreamIsCompleteCallback verifies the isComplete/extractResult
// callbacks auto-capture the result on the terminal event.
func TestEventStreamIsCompleteCallback(t *testing.T) {
s := NewEventStream[AgentEvent, []AgentMessage](4)
s.IsComplete = func(e AgentEvent) bool { return e.EventType() == EventAgentEnd }
s.ExtractResult = func(e AgentEvent) []AgentMessage { return e.(AgentEndEvent).Messages }
msgs := []AgentMessage{AssistantMessage{RoleField: RoleAssistant}}
go func() {
ctx := context.Background()
_ = s.Emit(ctx, MessageStartEvent{})
_ = s.Emit(ctx, AgentEndEvent{Messages: msgs})
s.Close()
}()
for range s.Events() {
}
res, err := s.Result(context.Background())
if err != nil {
t.Fatalf("result: %v", err)
}
if len(res) != 1 {
t.Fatalf("want 1 msg from extractResult, got %d", len(res))
}
}
// TestEventStreamCancellation verifies that a cancelled context unblocks a
// producer stuck on Emit (consumer stopped reading) and that Result returns the
// context error.
func TestEventStreamCancellation(t *testing.T) {
s := NewEventStream[AgentEvent, []AgentMessage](0)
ctx, cancel := context.WithCancel(context.Background())
emitErr := make(chan error, 1)
go func() {
// First emit has no consumer; it blocks until cancel.
emitErr <- s.Emit(ctx, TurnStartEvent{})
}()
// Give the producer a moment to block on the send, then cancel.
time.Sleep(20 * time.Millisecond)
cancel()
select {
case err := <-emitErr:
if err == nil {
t.Fatal("expected Emit to return ctx error on cancellation")
}
case <-time.After(time.Second):
t.Fatal("Emit did not unblock after cancel (goroutine leak)")
}
// Result with a cancelled context returns promptly with the ctx error.
if _, err := s.Result(ctx); err == nil {
t.Fatal("expected Result to return ctx error")
}
}
// TestEventStreamIncompleteClose verifies Close without a result yields
// ErrStreamIncomplete.
func TestEventStreamIncompleteClose(t *testing.T) {
s := NewEventStream[AgentEvent, []AgentMessage](1)
s.Close()
if _, err := s.Result(context.Background()); err != ErrStreamIncomplete {
t.Fatalf("want ErrStreamIncomplete, got %v", err)
}
}
// TestEventStreamSetErrorWins verifies SetError is reported and later SetResult
// is ignored (first outcome wins).
func TestEventStreamSetErrorWins(t *testing.T) {
s := NewEventStream[AgentEvent, []AgentMessage](1)
sentinel := context.Canceled
s.SetError(sentinel)
s.SetResult(nil)
s.Close()
if _, err := s.Result(context.Background()); err != sentinel {
t.Fatalf("want sentinel error, got %v", err)
}
}
+60
View File
@@ -0,0 +1,60 @@
package agentcore
import (
"context"
"encoding/json"
"strings"
)
// ContentToText flattens text blocks of a content list into a single string,
// the lowest-common-denominator representation accepted by every OpenAI-
// compatible gateway. Non-text blocks (thinking, tool calls) are surfaced
// through their own fields, so they are skipped here.
func ContentToText(list ContentList) string {
var b strings.Builder
for _, c := range list {
if tc, ok := c.(TextContent); ok {
b.WriteString(tc.Text)
}
}
return b.String()
}
// LastAssistantOf returns a pointer to the last AssistantMessage in msgs, or nil.
func LastAssistantOf(msgs []AgentMessage) *AssistantMessage {
for i := len(msgs) - 1; i >= 0; i-- {
if a, ok := msgs[i].(AssistantMessage); ok {
return &a
}
}
return nil
}
// EmitFunc emits a loop-level AgentEvent honoring cancellation.
type EmitFunc func(ctx context.Context, ev AgentEvent) error
// PrepareArgumentsFunc optionally rewrites a tool's raw arguments before schema
// validation (e.g. injecting defaults). An error aborts the call with an error
// result. Optional (nil = identity).
type PrepareArgumentsFunc func(ctx context.Context, toolName string, args json.RawMessage) (json.RawMessage, error)
// BeforeToolCallDecision is the optional result of the beforeToolCall hook. When
// Block is true the tool is not executed and an error result is produced;
// Content/Details override the default block message when set. When Block is
// false and UpdatedInput is non-empty, it replaces the tool's raw arguments
// before execution (PreToolUse rewrite, FR-8); the replacement is re-validated
// against the tool schema.
type BeforeToolCallDecision struct {
Block bool
Content *ContentList
Details *any
UpdatedInput json.RawMessage
}
// BeforeToolCallFunc runs after validation and may block the call (permission /
// sandbox checks, FR-4/FR-26). Returning nil allows the call. Optional.
type BeforeToolCallFunc func(ctx context.Context, call AgentToolCall) *BeforeToolCallDecision
// AfterToolCallFunc runs after execution and may override the result
// field-by-field via AfterToolCallResult (FR-5, no deep merge). Optional.
type AfterToolCallFunc func(ctx context.Context, call AgentToolCall, result AgentToolResult, isError bool) *AfterToolCallResult
+141
View File
@@ -0,0 +1,141 @@
package agentcore
import (
"encoding/json"
"testing"
)
func TestContentToText(t *testing.T) {
cases := []struct {
name string
list ContentList
want string
}{
{"empty", nil, ""},
{"single text", ContentList{NewTextContent("hello")}, "hello"},
{
"skips non-text blocks",
ContentList{
NewTextContent("a"),
NewThinkingContent("ignored"),
NewToolCallContent("c1", "ls", json.RawMessage(`{}`)),
NewTextContent("b"),
NewImageContent("data", "image/png"),
},
"ab",
},
{
"only non-text",
ContentList{NewThinkingContent("x"), NewImageContent("d", "image/png")},
"",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ContentToText(tc.list); got != tc.want {
t.Errorf("ContentToText = %q, want %q", got, tc.want)
}
})
}
}
func TestLastAssistantOf(t *testing.T) {
t.Run("nil when absent", func(t *testing.T) {
msgs := []AgentMessage{
UserMessage{RoleField: RoleUser, Content: ContentList{NewTextContent("hi")}},
ToolResultMessage{RoleField: RoleToolResult, ToolCallID: "c1"},
}
if got := LastAssistantOf(msgs); got != nil {
t.Errorf("want nil, got %+v", got)
}
})
t.Run("nil for empty slice", func(t *testing.T) {
if got := LastAssistantOf(nil); got != nil {
t.Errorf("want nil, got %+v", got)
}
})
t.Run("returns last assistant", func(t *testing.T) {
msgs := []AgentMessage{
AssistantMessage{RoleField: RoleAssistant, Model: "first"},
UserMessage{RoleField: RoleUser},
AssistantMessage{RoleField: RoleAssistant, Model: "last"},
ToolResultMessage{RoleField: RoleToolResult},
}
got := LastAssistantOf(msgs)
if got == nil {
t.Fatal("want an assistant message, got nil")
}
if got.Model != "last" {
t.Errorf("want the last assistant (model %q), got %q", "last", got.Model)
}
})
}
func TestToolCallsEmpty(t *testing.T) {
m := AssistantMessage{
RoleField: RoleAssistant,
Content: ContentList{NewTextContent("no tools here"), NewThinkingContent("hmm")},
}
if calls := m.ToolCalls(); calls != nil {
t.Errorf("want nil for a message with no tool calls, got %+v", calls)
}
}
func TestToolCallsPreservesOrder(t *testing.T) {
m := AssistantMessage{
RoleField: RoleAssistant,
Content: ContentList{
NewToolCallContent("c1", "read", json.RawMessage(`{}`)),
NewTextContent("between"),
NewToolCallContent("c2", "write", json.RawMessage(`{}`)),
},
}
calls := m.ToolCalls()
if len(calls) != 2 {
t.Fatalf("want 2 tool calls, got %d", len(calls))
}
if calls[0].ID != "c1" || calls[1].ID != "c2" {
t.Errorf("tool call order lost: %+v", calls)
}
}
// TestNewContentConstructorsSetType guards the invariant that every constructor
// sets its type discriminant, so a marshalled block always carries a "type".
func TestNewContentConstructorsSetType(t *testing.T) {
cases := []struct {
got Content
want string
}{
{NewTextContent("t"), ContentTypeText},
{NewThinkingContent("th"), ContentTypeThinking},
{NewToolCallContent("id", "n", json.RawMessage(`{}`)), ContentTypeToolCall},
{NewImageContent("d", "image/png"), ContentTypeImage},
}
for _, tc := range cases {
data, err := json.Marshal(tc.got)
if err != nil {
t.Fatalf("marshal %T: %v", tc.got, err)
}
var probe struct {
Type string `json:"type"`
}
if err := json.Unmarshal(data, &probe); err != nil {
t.Fatalf("unmarshal probe %T: %v", tc.got, err)
}
if probe.Type != tc.want {
t.Errorf("%T type = %q, want %q", tc.got, probe.Type, tc.want)
}
}
}
func TestRoleAccessors(t *testing.T) {
if got := (UserMessage{}).Role(); got != RoleUser {
t.Errorf("UserMessage.Role = %q, want %q", got, RoleUser)
}
if got := (AssistantMessage{}).Role(); got != RoleAssistant {
t.Errorf("AssistantMessage.Role = %q, want %q", got, RoleAssistant)
}
if got := (ToolResultMessage{}).Role(); got != RoleToolResult {
t.Errorf("ToolResultMessage.Role = %q, want %q", got, RoleToolResult)
}
}
+44
View File
@@ -0,0 +1,44 @@
package agentcore
// ThinkingLevel is the unified reasoning-effort enum (agent layer). It keeps
// pi's full 6 levels; providers map it to their own wire format via a
// per-model ThinkingLevelMap (decision #10).
type ThinkingLevel string
const (
ThinkingOff ThinkingLevel = "off"
ThinkingMinimal ThinkingLevel = "minimal"
ThinkingLow ThinkingLevel = "low"
ThinkingMedium ThinkingLevel = "medium"
ThinkingHigh ThinkingLevel = "high"
ThinkingXHigh ThinkingLevel = "xhigh"
ThinkingMax ThinkingLevel = "max"
)
// ThinkingLevelMap maps a unified level to a provider-specific wire value.
// A nil value means "supported but disabled at this level"; an absent key means
// "this level is not supported by the model". The pointer is what distinguishes
// those two cases, so it must stay *string.
type ThinkingLevelMap map[ThinkingLevel]*string
// AfterToolCallResult is the optional override returned by the afterToolCall
// hook. Every field is a pointer so the loop can distinguish "not provided"
// (nil) from "provided, possibly zero" — pi expresses this with `??`, Go needs
// pointers. Fields are applied with field-level replacement, no deep merge
// (FR-5).
type AfterToolCallResult struct {
Content *ContentList
Details *any
Terminate *bool
IsError *bool
}
// AgentLoopTurnUpdate is the optional result of the prepareNextTurn hook: it can
// swap the context, model, or thinking level for the next turn. Pointer fields
// distinguish "not provided" from an explicit value; ThinkingLevel is
// three-state (nil = keep, &"off" = disable, &level = set).
type AgentLoopTurnUpdate struct {
Context *AgentContext
Model *string
ThinkingLevel *ThinkingLevel
}
+210
View File
@@ -0,0 +1,210 @@
package agentcore
import (
"encoding/json"
"fmt"
)
// Message roles, matching pi's wire format.
const (
RoleUser = "user"
RoleAssistant = "assistant"
RoleToolResult = "toolResult"
// RoleCompaction marks a compaction checkpoint persisted inline in the
// message list: it replaces the history summarized before it (pi's
// "compactionSummary"). It is not sent to the model verbatim; the LLM
// conversion turns it into a user text block.
RoleCompaction = "compaction"
)
// Message is the sealed interface implemented by the three message roles.
// AgentMessage (the loop's message abstraction) is simply Message: custom
// message kinds implement the same interface and convertToLlm filters out any
// that are not LLM-bound. This deliberately replaces pi's declaration merging,
// which has no Go equivalent.
type Message interface {
isMessage()
// Role returns the discriminant ("user" | "assistant" | "toolResult").
Role() string
}
// AgentMessage is the loop-level message type. It is the same as Message; the
// alias documents intent at call sites that deal with the loop rather than raw
// LLM messages.
type AgentMessage = Message
// Usage reports token accounting for an assistant response.
type Usage struct {
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
}
// UserMessage is input from the user. Content is restricted at construction to
// text/image blocks (runtime constraint, not a separate interface).
type UserMessage struct {
RoleField string `json:"role"`
Content ContentList `json:"content"`
Timestamp int64 `json:"timestamp"`
}
func (UserMessage) isMessage() {}
func (m UserMessage) Role() string { return RoleUser }
// AssistantMessage is a model response. Content may hold text/thinking/toolCall
// blocks. StopReason follows pi's set (end_turn/tool_use/length/error/aborted).
type AssistantMessage struct {
RoleField string `json:"role"`
Content ContentList `json:"content"`
API string `json:"api,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Usage *Usage `json:"usage,omitempty"`
StopReason string `json:"stopReason,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
Timestamp int64 `json:"timestamp"`
// Optional diagnostics, kept for cross-provider replay/observability.
ResponseModel string `json:"responseModel,omitempty"`
ResponseID string `json:"responseId,omitempty"`
}
func (AssistantMessage) isMessage() {}
func (m AssistantMessage) Role() string { return RoleAssistant }
// ToolCalls returns the tool call blocks in this assistant message, in order.
func (m AssistantMessage) ToolCalls() []ToolCallContent {
var calls []ToolCallContent
for _, c := range m.Content {
if tc, ok := c.(ToolCallContent); ok {
calls = append(calls, tc)
}
}
return calls
}
// ToolResultMessage carries the outcome of executing a single tool call.
// Content is restricted to text/image blocks at construction.
type ToolResultMessage struct {
RoleField string `json:"role"`
ToolCallID string `json:"toolCallId"`
ToolName string `json:"toolName"`
Content ContentList `json:"content"`
Details any `json:"details,omitempty"`
IsError bool `json:"isError"`
Timestamp int64 `json:"timestamp"`
}
func (ToolResultMessage) isMessage() {}
func (m ToolResultMessage) Role() string { return RoleToolResult }
// CompactionMessage is a summarization checkpoint persisted inline in the
// message list. It stands in for the history compacted before it: Summary is
// the structured checkpoint text and TokensBefore records the estimated context
// size at compaction time (for observability). Details optionally holds the
// file operations extracted from the compacted range. Mirrors pi's
// CompactionSummaryMessage + CompactionEntry.
type CompactionMessage struct {
RoleField string `json:"role"`
Summary string `json:"summary"`
TokensBefore int `json:"tokensBefore,omitempty"`
// Details is opaque at this layer (the compaction package owns its shape);
// kept as raw JSON so agentcore stays free of a compaction dependency.
Details json.RawMessage `json:"details,omitempty"`
Timestamp int64 `json:"timestamp"`
}
func (CompactionMessage) isMessage() {}
func (m CompactionMessage) Role() string { return RoleCompaction }
// compactionSummaryPrefix / compactionSummarySuffix wrap a compaction summary
// when it is rendered into an LLM user message, matching pi's
// COMPACTION_SUMMARY_PREFIX / COMPACTION_SUMMARY_SUFFIX.
const (
compactionSummaryPrefix = "The conversation history before this point was compacted into the following summary:\n\n<summary>\n"
compactionSummarySuffix = "\n</summary>"
)
// AsUserMessage renders a compaction checkpoint as the user text message that
// stands in for the compacted history when building the LLM request. The
// provider encoders call this so a persisted compaction line replays as
// context rather than being dropped.
func (m CompactionMessage) AsUserMessage() UserMessage {
return UserMessage{
RoleField: RoleUser,
Content: ContentList{NewTextContent(compactionSummaryPrefix + m.Summary + compactionSummarySuffix)},
Timestamp: m.Timestamp,
}
}
// StopReason values, matching pi.
const (
StopReasonEndTurn = "end_turn"
StopReasonToolUse = "tool_use"
StopReasonLength = "length"
StopReasonError = "error"
StopReasonAborted = "aborted"
)
// MessageList is a slice of Message with discriminated JSON (un)marshalling,
// dispatching on the "role" field. Used by AgentContext and session persistence.
type MessageList []Message
// UnmarshalJSON decodes a JSON array of messages, dispatching each element on
// its "role" discriminant.
func (ml *MessageList) UnmarshalJSON(data []byte) error {
var raws []json.RawMessage
if err := json.Unmarshal(data, &raws); err != nil {
return err
}
out := make(MessageList, 0, len(raws))
for i, raw := range raws {
m, err := decodeMessage(raw)
if err != nil {
return fmt.Errorf("message[%d]: %w", i, err)
}
out = append(out, m)
}
*ml = out
return nil
}
// decodeMessage peeks at the "role" field and decodes into the matching
// concrete message struct.
func decodeMessage(raw json.RawMessage) (Message, error) {
var probe struct {
Role string `json:"role"`
}
if err := json.Unmarshal(raw, &probe); err != nil {
return nil, fmt.Errorf("peek role: %w", err)
}
switch probe.Role {
case RoleUser:
var m UserMessage
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
return m, nil
case RoleAssistant:
var m AssistantMessage
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
return m, nil
case RoleToolResult:
var m ToolResultMessage
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
return m, nil
case RoleCompaction:
var m CompactionMessage
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
return m, nil
case "":
return nil, fmt.Errorf("missing role discriminant")
default:
return nil, fmt.Errorf("unknown role %q", probe.Role)
}
}
+22
View File
@@ -0,0 +1,22 @@
package agentcore
import "context"
// progressEmitterKey is the unexported context key under which a run-level
// progress EmitFunc is stored.
type progressEmitterKey struct{}
// WithProgressEmitter returns a child context carrying emit as the run-level
// progress emitter. The task tool injects the parent loop's EmitFunc here so a
// dispatched sub-agent can surface SubAgentProgressEvent up the parent stream.
func WithProgressEmitter(ctx context.Context, emit EmitFunc) context.Context {
return context.WithValue(ctx, progressEmitterKey{}, emit)
}
// ProgressEmitterFromContext returns the run-level progress emitter carried by
// ctx, or nil if none was set (in which case callers should skip progress
// reporting rather than panic).
func ProgressEmitterFromContext(ctx context.Context) EmitFunc {
emit, _ := ctx.Value(progressEmitterKey{}).(EmitFunc)
return emit
}
@@ -0,0 +1,51 @@
package agentcore
import (
"context"
"errors"
"testing"
)
func TestSubAgentProgressEventImplementsAgentEvent(t *testing.T) {
var ev AgentEvent = SubAgentProgressEvent{
ToolCallID: "call-1",
Description: "do a thing",
Activity: "Editing",
Tokens: 42,
}
if got := ev.EventType(); got != EventSubAgentProgress {
t.Fatalf("EventType() = %q, want %q", got, EventSubAgentProgress)
}
if EventSubAgentProgress != "subagent_progress" {
t.Fatalf("EventSubAgentProgress = %q, want %q", EventSubAgentProgress, "subagent_progress")
}
}
func TestProgressEmitterRoundTrip(t *testing.T) {
var seen AgentEvent
sentinel := errors.New("sentinel")
emit := func(ctx context.Context, ev AgentEvent) error {
seen = ev
return sentinel
}
ctx := WithProgressEmitter(context.Background(), emit)
got := ProgressEmitterFromContext(ctx)
if got == nil {
t.Fatal("ProgressEmitterFromContext returned nil after WithProgressEmitter")
}
want := SubAgentProgressEvent{ToolCallID: "call-2", Activity: "Thinking"}
if err := got(ctx, want); !errors.Is(err, sentinel) {
t.Fatalf("emitter returned err = %v, want sentinel", err)
}
if seen != want {
t.Fatalf("emitter received %#v, want %#v", seen, want)
}
}
func TestProgressEmitterFromBareContextIsNil(t *testing.T) {
if got := ProgressEmitterFromContext(context.Background()); got != nil {
t.Fatalf("ProgressEmitterFromContext on bare ctx = %v, want nil", got)
}
}
+61
View File
@@ -0,0 +1,61 @@
package agentcore
import (
"context"
"encoding/json"
)
// AgentContext is the input state for a loop run: system prompt, conversation
// messages, and the tools available to the model.
type AgentContext struct {
SystemPrompt string `json:"systemPrompt"`
Messages MessageList `json:"messages"`
Tools []AgentTool `json:"-"`
}
// ToolExecutionMode selects how a tool is executed relative to others in a batch.
type ToolExecutionMode string
const (
// ToolExecutionParallel allows the tool to run concurrently with others.
ToolExecutionParallel ToolExecutionMode = "parallel"
// ToolExecutionSequential forces the whole batch to run serially.
ToolExecutionSequential ToolExecutionMode = "sequential"
)
// ToolUpdateFunc receives a partial result during tool execution; the loop
// turns each call into a tool_execution_update event.
type ToolUpdateFunc func(partial AgentToolResult)
// AgentTool is a tool the model can invoke. Schema is the JSON Schema used to
// validate arguments before execution (US-014).
type AgentTool interface {
Name() string
Description() string
// Schema returns the JSON Schema (as raw JSON) for the tool's arguments.
Schema() json.RawMessage
// ExecutionMode reports whether this tool forces sequential execution.
ExecutionMode() ToolExecutionMode
// Execute runs the tool. onUpdate may be nil.
Execute(ctx context.Context, id string, args json.RawMessage, onUpdate ToolUpdateFunc) (AgentToolResult, error)
}
// AgentToolCall is a decoded request to invoke a tool (the loop-level view of a
// ToolCallContent block).
type AgentToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
// AgentToolResult is the outcome of executing a tool.
//
// Details uses `any` in the first version (matching pi's internal
// AgentToolResult<any>); a generic form can be added later. Terminate is a
// *bool so "not set" is distinguishable from an explicit false — the loop only
// signals early termination when every result in a batch has Terminate=true.
type AgentToolResult struct {
Content ContentList `json:"content"`
Details any `json:"details,omitempty"`
Terminate *bool `json:"terminate,omitempty"`
}
+182
View File
@@ -0,0 +1,182 @@
package agentcore
import (
"encoding/json"
"testing"
)
func TestContentListRoundTrip(t *testing.T) {
in := ContentList{
NewTextContent("hello"),
NewThinkingContent("pondering"),
NewToolCallContent("call_1", "read", json.RawMessage(`{"path":"a.go"}`)),
NewImageContent("YmFzZTY0", "image/png"),
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out ContentList
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out) != 4 {
t.Fatalf("want 4 blocks, got %d", len(out))
}
if _, ok := out[0].(TextContent); !ok {
t.Errorf("block 0: want TextContent, got %T", out[0])
}
if _, ok := out[1].(ThinkingContent); !ok {
t.Errorf("block 1: want ThinkingContent, got %T", out[1])
}
tc, ok := out[2].(ToolCallContent)
if !ok {
t.Fatalf("block 2: want ToolCallContent, got %T", out[2])
}
if tc.ID != "call_1" || tc.Name != "read" {
t.Errorf("toolCall fields lost: %+v", tc)
}
if string(tc.Arguments) != `{"path":"a.go"}` {
t.Errorf("arguments lost: %s", tc.Arguments)
}
if _, ok := out[3].(ImageContent); !ok {
t.Errorf("block 3: want ImageContent, got %T", out[3])
}
}
func TestContentUnknownTypeRejected(t *testing.T) {
var out ContentList
err := json.Unmarshal([]byte(`[{"type":"bogus"}]`), &out)
if err == nil {
t.Fatal("expected error for unknown content type")
}
}
// TestToolCallInvalidArgumentsMarshal verifies a ToolCallContent whose
// Arguments are syntactically invalid JSON (as a model can stream) still
// marshals — as a JSON string of the raw bytes — rather than aborting the
// encode. Without this, session persistence and provider re-serialization would
// crash the whole turn on a single malformed tool call.
func TestToolCallInvalidArgumentsMarshal(t *testing.T) {
bad := NewToolCallContent("c1", "todo", json.RawMessage(`{"todos": []{}"content": ""x"}`))
data, err := json.Marshal(bad)
if err != nil {
t.Fatalf("marshal invalid tool args: %v", err)
}
if !json.Valid(data) {
t.Fatalf("marshaled output is not valid JSON: %s", data)
}
// It must round-trip back through the discriminated decoder without error.
var out ContentList
if err := json.Unmarshal([]byte("["+string(data)+"]"), &out); err != nil {
t.Fatalf("round-trip unmarshal: %v", err)
}
tc, ok := out[0].(ToolCallContent)
if !ok {
t.Fatalf("want ToolCallContent, got %T", out[0])
}
// The raw invalid text is preserved (as the decoded string).
var recovered string
if err := json.Unmarshal(tc.Arguments, &recovered); err != nil {
t.Fatalf("arguments not a JSON string: %v", err)
}
if recovered != `{"todos": []{}"content": ""x"}` {
t.Errorf("raw arguments lost: %q", recovered)
}
}
// TestToolCallValidArgumentsUnchanged verifies well-formed arguments are emitted
// verbatim (not string-wrapped), preserving the object shape providers expect.
func TestToolCallValidArgumentsUnchanged(t *testing.T) {
tc := NewToolCallContent("c1", "read", json.RawMessage(`{"path":"a.go"}`))
data, err := json.Marshal(tc)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out ContentList
if err := json.Unmarshal([]byte("["+string(data)+"]"), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
got := out[0].(ToolCallContent)
if string(got.Arguments) != `{"path":"a.go"}` {
t.Errorf("arguments = %s, want the object unchanged", got.Arguments)
}
}
func TestContentMissingTypeRejected(t *testing.T) {
var out ContentList
err := json.Unmarshal([]byte(`[{"text":"no type"}]`), &out)
if err == nil {
t.Fatal("expected error for missing type discriminant")
}
}
func TestMessageListRoundTrip(t *testing.T) {
term := true
_ = term
in := MessageList{
UserMessage{RoleField: RoleUser, Content: ContentList{NewTextContent("hi")}, Timestamp: 1},
AssistantMessage{
RoleField: RoleAssistant,
Content: ContentList{NewTextContent("ok"), NewToolCallContent("c1", "ls", json.RawMessage(`{}`))},
StopReason: StopReasonToolUse,
Timestamp: 2,
},
ToolResultMessage{RoleField: RoleToolResult, ToolCallID: "c1", ToolName: "ls", Content: ContentList{NewTextContent("file.go")}, Timestamp: 3},
}
data, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out MessageList
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out) != 3 {
t.Fatalf("want 3 messages, got %d", len(out))
}
if out[0].Role() != RoleUser {
t.Errorf("msg 0: want user, got %s", out[0].Role())
}
am, ok := out[1].(AssistantMessage)
if !ok {
t.Fatalf("msg 1: want AssistantMessage, got %T", out[1])
}
if calls := am.ToolCalls(); len(calls) != 1 || calls[0].Name != "ls" {
t.Errorf("assistant ToolCalls wrong: %+v", calls)
}
if out[2].Role() != RoleToolResult {
t.Errorf("msg 2: want toolResult, got %s", out[2].Role())
}
}
func TestMessageUnknownRoleRejected(t *testing.T) {
var out MessageList
if err := json.Unmarshal([]byte(`[{"role":"system"}]`), &out); err == nil {
t.Fatal("expected error for unknown role")
}
}
// TestAgentEventCoverage asserts all 10 event types report a distinct,
// non-empty discriminant (PRD FR-24).
func TestAgentEventCoverage(t *testing.T) {
events := []AgentEvent{
AgentStartEvent{}, AgentEndEvent{}, TurnStartEvent{}, TurnEndEvent{},
MessageStartEvent{}, MessageUpdateEvent{}, MessageEndEvent{},
ToolExecutionStartEvent{}, ToolExecutionUpdateEvent{}, ToolExecutionEndEvent{},
}
seen := map[string]bool{}
for _, e := range events {
et := e.EventType()
if et == "" {
t.Errorf("%T has empty EventType", e)
}
if seen[et] {
t.Errorf("duplicate event type %q", et)
}
seen[et] = true
}
if len(seen) != 10 {
t.Fatalf("want 10 distinct event types, got %d", len(seen))
}
}