first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user