first commit
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
// This file implements pi's two-layer agent loop (US-006, FR-1). It strings
|
||||
// together streaming assistant responses, batch tool execution, and the loop's
|
||||
// six hooks with control flow kept faithful to pi's runLoop:
|
||||
//
|
||||
// - Inner loop: one turn = stream an assistant response → execute its tool
|
||||
// calls → feed the results back, repeating until an assistant message has no
|
||||
// tool calls (a natural turn end).
|
||||
// - Outer loop: after the inner loop settles, pull getFollowUpMessages; if any
|
||||
// are returned they become the next pending input and the inner loop runs
|
||||
// again, otherwise the run ends.
|
||||
//
|
||||
// Per-turn hooks after each turn_end: getSteeringMessages (pulled after tool
|
||||
// execution and injected before the next turn), prepareNextTurn (may swap
|
||||
// context / model / thinkingLevel), shouldStopAfterTurn (true ⇒ agent_end +
|
||||
// exit). Two stop reasons are handled specially: length (the response was
|
||||
// truncated by the token cap) fails every tool call so the model resends
|
||||
// (failToolCallsFromTruncatedMessage); error / aborted end the run immediately.
|
||||
//
|
||||
// agentLoop starts a fresh run from a prompt already appended to the context.
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"github.com/smallnest/pigo/internal/agenttool"
|
||||
"github.com/smallnest/pigo/internal/compaction"
|
||||
"github.com/smallnest/pigo/internal/provider"
|
||||
)
|
||||
|
||||
// nowMillis returns the current Unix time in milliseconds, the timestamp unit
|
||||
// used for CompactionMessage checkpoints.
|
||||
func nowMillis() int64 { return time.Now().UnixMilli() }
|
||||
|
||||
// TurnUpdate is the optional result of PrepareNextTurn: any non-nil field
|
||||
// replaces the corresponding piece of loop state before the next turn. It lets
|
||||
// a caller swap the trimmed context, system prompt, tool set, model, or
|
||||
// thinking level between turns (FR-6).
|
||||
type TurnUpdate struct {
|
||||
Messages *agentcore.MessageList
|
||||
SystemPrompt *string
|
||||
Tools *[]agentcore.AgentTool
|
||||
Model *string
|
||||
ThinkingLevel *agentcore.ThinkingLevel
|
||||
}
|
||||
|
||||
// StopDecision is the result of the OnStop seam. Block=true prevents the run
|
||||
// from ending; Guidance, when non-empty, is appended as a user-role message to
|
||||
// steer the forced continuation (the Stop / SubagentStop hook's reason). The
|
||||
// zero value (Block=false) lets the run end.
|
||||
type StopDecision struct {
|
||||
Block bool
|
||||
Guidance string
|
||||
}
|
||||
|
||||
// RunConfig is the full configuration for a loop run: the per-turn streaming
|
||||
// config (embedded LoopConfig), the batch tool-execution config, and the four
|
||||
// loop-level hooks. Every hook is optional (nil = default behavior).
|
||||
type RunConfig struct {
|
||||
LoopConfig
|
||||
// Batch holds the tool registry and the prepare/before/after hooks used to
|
||||
// execute each assistant message's tool calls.
|
||||
Batch agenttool.BatchConfig
|
||||
|
||||
// GetFollowUpMessages is consulted after the inner loop settles (an assistant
|
||||
// message with no tool calls). Returning messages continues the outer loop
|
||||
// with them as the next input; returning none ends the run (FR-9).
|
||||
GetFollowUpMessages func(ctx context.Context, agentCtx *agentcore.AgentContext) []agentcore.AgentMessage
|
||||
// GetSteeringMessages is pulled after each turn's tool execution and injected
|
||||
// before the next turn (pi per-turn semantics, FR-8).
|
||||
GetSteeringMessages func(ctx context.Context) []agentcore.AgentMessage
|
||||
// PrepareNextTurn runs after each turn_end and may swap context / model /
|
||||
// thinkingLevel for the next turn (FR-6).
|
||||
PrepareNextTurn func(ctx context.Context, agentCtx *agentcore.AgentContext) *TurnUpdate
|
||||
// ShouldStopAfterTurn runs after each turn_end; true ends the run with an
|
||||
// agent_end event (FR-7).
|
||||
ShouldStopAfterTurn func(ctx context.Context, agentCtx *agentcore.AgentContext) bool
|
||||
|
||||
// OnStop, when set, is consulted right before the run would end naturally (no
|
||||
// tool calls and no follow-up messages). Returning a decision with Block=true
|
||||
// keeps the loop running: any Guidance is appended as a user-role message to
|
||||
// steer the continued run (Stop / SubagentStop hooks, US-008/009, FR-10). The
|
||||
// seam carries no loop-protection itself — the caller's decorator owns the
|
||||
// consecutive-block counter and the FR-12 force-stop limit, so an ill-behaved
|
||||
// hook cannot loop forever. nil or a non-blocking decision lets the run end.
|
||||
OnStop func(ctx context.Context, agentCtx *agentcore.AgentContext) *StopDecision
|
||||
|
||||
// Reminders holds the per-turn system-reminder providers (US-002, FR-1/FR-2).
|
||||
// When non-empty, ephemeral <system-reminder> messages are injected into each
|
||||
// turn's LLM request through the existing TransformContext seam, so they never
|
||||
// enter the persisted history. nil / empty = no injection.
|
||||
Reminders *ReminderRegistry
|
||||
|
||||
// EventBuffer is the buffer size of the emitted EventStream. 0 gives fully
|
||||
// synchronous back-pressure (matching pi's awaited emit).
|
||||
EventBuffer int
|
||||
|
||||
// SessionID, when set, is carried in the run's agent_start event so a
|
||||
// stream-json consumer sees the backing session id in the first event and can
|
||||
// resume the run later (mirrors pi/Claude Code). It is also the session key
|
||||
// under which auto-compaction checkpoints are persisted (see MemoryRoot).
|
||||
SessionID string
|
||||
|
||||
// MemoryRoot, when non-empty (together with SessionID), enables checkpoint
|
||||
// persistence for the "infinite context" feature (#480/#481): after a
|
||||
// successful auto-compaction the collapsed prefix's summary is written as a
|
||||
// checkpoint under <MemoryRoot>/sessions/<SessionID>/checkpoint.md so a later
|
||||
// run can reload it instead of replaying the whole transcript. It is left ""
|
||||
// when persistent memory is disabled (memory.enabled=false), which fully
|
||||
// disables checkpoint writing. A checkpoint write failure is non-fatal.
|
||||
MemoryRoot string
|
||||
}
|
||||
|
||||
// LoopEventStream is the stream returned by the loop entry points: it carries
|
||||
// AgentEvents and yields the messages newly produced during the run.
|
||||
type LoopEventStream = agentcore.EventStream[agentcore.AgentEvent, []agentcore.AgentMessage]
|
||||
|
||||
// agentLoop starts a fresh run. The caller has already appended the initiating
|
||||
// user message(s) to agentCtx.Messages. It returns immediately with an
|
||||
// EventStream; a producer goroutine drives the loop and closes the stream when
|
||||
// the run ends.
|
||||
func agentLoop(ctx context.Context, agentCtx *agentcore.AgentContext, cfg RunConfig) *LoopEventStream {
|
||||
stream := agentcore.NewEventStream[agentcore.AgentEvent, []agentcore.AgentMessage](cfg.EventBuffer)
|
||||
go runLoop(ctx, agentCtx, cfg, stream)
|
||||
return stream
|
||||
}
|
||||
|
||||
// StartRun is the exported entry point for a fresh run, used by out-of-package
|
||||
// drivers (the interactive REPL, US-022). It is a thin wrapper over agentLoop so
|
||||
// the loop internals stay unexported while callers outside the package can
|
||||
// still launch a run and consume its event stream.
|
||||
func StartRun(ctx context.Context, agentCtx *agentcore.AgentContext, cfg RunConfig) *LoopEventStream {
|
||||
return agentLoop(ctx, agentCtx, cfg)
|
||||
}
|
||||
|
||||
// runLoop is the producer: it drives the two-layer loop, emitting events onto
|
||||
// stream and setting the stream result to the messages produced during the run.
|
||||
func runLoop(ctx context.Context, agentCtx *agentcore.AgentContext, cfg RunConfig, stream *LoopEventStream) {
|
||||
// Wire per-turn system-reminder injection (US-002) onto the TransformContext
|
||||
// seam. Reminders are appended to the request-shaped copy only, so they stay
|
||||
// ephemeral: never written back to agentCtx.Messages, never persisted, never
|
||||
// swept into a compaction summary.
|
||||
if !cfg.Reminders.Empty() {
|
||||
cfg.TransformContext = cfg.Reminders.wrapTransform(cfg.TransformContext)
|
||||
}
|
||||
startIdx := len(agentCtx.Messages)
|
||||
// tel accumulates structured telemetry (turn count, per-tool durations,
|
||||
// truncation count, compaction count, latest context-utilization ratio) from
|
||||
// the events emitted below, surfaced as a TelemetryEvent at run end.
|
||||
tel := newTelemetry()
|
||||
// newMessages returns the messages appended since the run began.
|
||||
newMessages := func() []agentcore.AgentMessage {
|
||||
if len(agentCtx.Messages) <= startIdx {
|
||||
return nil
|
||||
}
|
||||
out := make([]agentcore.AgentMessage, len(agentCtx.Messages)-startIdx)
|
||||
copy(out, agentCtx.Messages[startIdx:])
|
||||
return out
|
||||
}
|
||||
emit := func(ev agentcore.AgentEvent) error {
|
||||
tel.observe(ev)
|
||||
return stream.Emit(ctx, ev)
|
||||
}
|
||||
// emitFrom wraps the raw stream.Emit callback handed to streamAssistantResponse
|
||||
// and ExecuteToolCalls so telemetry observes those events (message_* and
|
||||
// tool_execution_*) too, without changing their signatures.
|
||||
emitFrom := func(c context.Context, ev agentcore.AgentEvent) error {
|
||||
tel.observe(ev)
|
||||
return stream.Emit(c, ev)
|
||||
}
|
||||
|
||||
// finish emits the telemetry summary then agent_end (unless suppressed by a
|
||||
// prior emit error), records the run result, and closes the stream exactly
|
||||
// once. Telemetry is emitted first so a consumer sees the run's structured
|
||||
// metrics immediately before the terminal event.
|
||||
finish := func() {
|
||||
_ = emit(tel.summary())
|
||||
msgs := newMessages()
|
||||
_ = emit(agentcore.AgentEndEvent{Messages: msgs})
|
||||
stream.SetResult(msgs)
|
||||
stream.Close()
|
||||
}
|
||||
|
||||
if err := emit(agentcore.AgentStartEvent{SessionID: cfg.SessionID}); err != nil {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
for { // outer loop: pending / follow-up messages
|
||||
for { // inner loop: turns until no tool calls
|
||||
if err := emit(agentcore.TurnStartEvent{}); err != nil {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
assistant, err := streamAssistantResponse(ctx, agentCtx, cfg.LoopConfig, emitFrom)
|
||||
if err != nil {
|
||||
// emit was cancelled mid-stream; end the run.
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
switch assistant.StopReason {
|
||||
case agentcore.StopReasonLength:
|
||||
// Truncated by the token cap: fail every tool call so the model
|
||||
// resends, then continue feeding back.
|
||||
toolResults := failToolCallsFromTruncatedMessage(agentCtx, assistant)
|
||||
if err := emit(agentcore.TurnEndEvent{Message: assistant, ToolResults: toolResults}); err != nil {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if afterTurn(ctx, agentCtx, &cfg, true, emit, tel) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
continue
|
||||
case agentcore.StopReasonError, agentcore.StopReasonAborted:
|
||||
// Terminal failure: emit the turn end and stop.
|
||||
_ = emit(agentcore.TurnEndEvent{Message: assistant})
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
calls := toAgentToolCalls(assistant.ToolCalls())
|
||||
if len(calls) == 0 {
|
||||
// Natural turn end: no tools to run.
|
||||
if err := emit(agentcore.TurnEndEvent{Message: assistant}); err != nil {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if afterTurn(ctx, agentCtx, &cfg, false, emit, tel) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
break // exit inner loop → consult follow-up messages
|
||||
}
|
||||
|
||||
// Inject the run-level emitter into the context so tools (notably the
|
||||
// generic task tool) can retrieve it via ProgressEmitterFromContext and
|
||||
// surface a dispatched sub-agent's progress up this parent event stream.
|
||||
// emitFrom feeds the parent stream and is run-scoped, so a child's
|
||||
// SubAgentProgressEvent lands on the right run's stream.
|
||||
toolCtx := agentcore.WithProgressEmitter(ctx, emitFrom)
|
||||
toolResults, allTerminate := agenttool.ExecuteToolCalls(toolCtx, cfg.Batch, calls, emitFrom)
|
||||
for _, tr := range toolResults {
|
||||
agentCtx.Messages = append(agentCtx.Messages, tr)
|
||||
}
|
||||
if err := emit(agentcore.TurnEndEvent{Message: assistant, ToolResults: toolResults}); err != nil {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if allTerminate {
|
||||
// Every tool asked to terminate the run.
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if afterTurn(ctx, agentCtx, &cfg, true, emit, tel) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// Feed the tool results back into the next turn.
|
||||
}
|
||||
|
||||
// Inner loop settled: consult follow-up messages.
|
||||
if cfg.GetFollowUpMessages != nil {
|
||||
if follow := cfg.GetFollowUpMessages(ctx, agentCtx); len(follow) > 0 {
|
||||
agentCtx.Messages = append(agentCtx.Messages, follow...)
|
||||
continue // outer loop with the follow-ups as new input
|
||||
}
|
||||
}
|
||||
// Stop hook: the run is about to end naturally. A hook may block the end
|
||||
// and force a continuation, feeding its guidance back as the next input
|
||||
// (US-008/009, FR-10). The consecutive-block counter and force-stop limit
|
||||
// (FR-12) live in the decorator behind OnStop, so this seam stays simple.
|
||||
if cfg.OnStop != nil {
|
||||
if dec := cfg.OnStop(ctx, agentCtx); dec != nil && dec.Block {
|
||||
if dec.Guidance != "" {
|
||||
agentCtx.Messages = append(agentCtx.Messages, agentcore.UserMessage{
|
||||
RoleField: agentcore.RoleUser,
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(dec.Guidance)},
|
||||
})
|
||||
}
|
||||
continue // outer loop: keep the run alive
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
finish()
|
||||
}
|
||||
|
||||
// afterTurn runs the per-turn hooks after a turn_end. When hadToolExecution is
|
||||
// true it first pulls getSteeringMessages and injects them before the next turn
|
||||
// (pi per-turn semantics). It then applies prepareNextTurn, runs auto-compaction
|
||||
// when the context has outgrown its window, and finally consults
|
||||
// shouldStopAfterTurn, returning true when the run should end.
|
||||
func afterTurn(ctx context.Context, agentCtx *agentcore.AgentContext, cfg *RunConfig, hadToolExecution bool, emit func(agentcore.AgentEvent) error, tel *telemetry) (stop bool) {
|
||||
if hadToolExecution && cfg.GetSteeringMessages != nil {
|
||||
if steer := cfg.GetSteeringMessages(ctx); len(steer) > 0 {
|
||||
agentCtx.Messages = append(agentCtx.Messages, steer...)
|
||||
}
|
||||
}
|
||||
if cfg.PrepareNextTurn != nil {
|
||||
if upd := cfg.PrepareNextTurn(ctx, agentCtx); upd != nil {
|
||||
applyTurnUpdate(agentCtx, cfg, upd)
|
||||
}
|
||||
}
|
||||
maybeAutoCompact(ctx, agentCtx, cfg, emit, tel)
|
||||
// Record the latest context-utilization ratio once the turn has settled (after
|
||||
// any compaction), so the telemetry summary reports the current used/window
|
||||
// figure. This runs even when auto-compaction is disabled so utilization is
|
||||
// still observable whenever the context window is known.
|
||||
if tel != nil && cfg.ContextWindow > 0 {
|
||||
tokens := compaction.EstimateContextTokens(agentCtx.Messages).Tokens
|
||||
tel.recordContext(tokens, cfg.ContextWindow)
|
||||
}
|
||||
if cfg.ShouldStopAfterTurn != nil {
|
||||
return cfg.ShouldStopAfterTurn(ctx, agentCtx)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// maybeAutoCompact checks whether the context has outgrown its usable window and,
|
||||
// if so, compacts it in place and emits a CompactionEvent. Compaction is a no-op
|
||||
// when disabled, when the context window is unknown (<= 0), or when usage is
|
||||
// under threshold. A compaction failure is non-fatal: the original context is
|
||||
// preserved and a CompactionEvent carrying ErrorMessage is emitted so the failure
|
||||
// is observable without aborting the run (US-004).
|
||||
func maybeAutoCompact(ctx context.Context, agentCtx *agentcore.AgentContext, cfg *RunConfig, emit func(agentcore.AgentEvent) error, tel *telemetry) {
|
||||
if !cfg.Compaction.Enabled || cfg.ContextWindow <= 0 {
|
||||
return
|
||||
}
|
||||
before := compaction.EstimateContextTokens(agentCtx.Messages).Tokens
|
||||
// Record pre-compaction utilization so the ratio reflects the peak that
|
||||
// triggered (or nearly triggered) compaction even when the summary is read
|
||||
// mid-run. afterTurn overwrites it with the post-settle figure.
|
||||
if tel != nil {
|
||||
tel.recordContext(before, cfg.ContextWindow)
|
||||
}
|
||||
if !compaction.ShouldCompact(before, cfg.ContextWindow, cfg.Compaction) {
|
||||
return
|
||||
}
|
||||
// Signal the start so a front-end can show an in-progress indicator while the
|
||||
// summarization request (an LLM call that blocks the loop) is in flight.
|
||||
_ = emit(agentcore.CompactionStartEvent{Reason: "threshold", TokensBefore: before})
|
||||
res, err := runCompaction(ctx, agentCtx.Messages, cfg)
|
||||
kept := len(agentCtx.Messages)
|
||||
if err != nil {
|
||||
_ = emit(agentcore.CompactionEvent{
|
||||
Reason: "threshold",
|
||||
TokensBefore: before,
|
||||
TokensAfter: before,
|
||||
KeptCount: kept,
|
||||
ErrorMessage: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if res == nil {
|
||||
// Nothing to summarize (cut point left no prefix); leave context as-is.
|
||||
return
|
||||
}
|
||||
// Persist a checkpoint of the collapsed prefix before rewriting the context so
|
||||
// a later run can reload it (infinite context, #480/#481). It reuses the
|
||||
// summary compaction just produced — no extra LLM call — and is best-effort:
|
||||
// a write failure is logged and the run continues on the compacted context.
|
||||
writeCompactionCheckpoint(ctx, agentCtx.Messages, res, cfg)
|
||||
now := nowMillis()
|
||||
rebuilt := res.RebuildContext(agentCtx.Messages, now)
|
||||
summarized := len(agentCtx.Messages) - (len(rebuilt) - 1)
|
||||
agentCtx.Messages = rebuilt
|
||||
after := compaction.EstimateContextTokens(rebuilt).Tokens
|
||||
_ = emit(agentcore.CompactionEvent{
|
||||
Reason: "threshold",
|
||||
TokensBefore: before,
|
||||
TokensAfter: after,
|
||||
SummarizedCount: summarized,
|
||||
KeptCount: len(rebuilt) - 1,
|
||||
})
|
||||
}
|
||||
|
||||
// runCompaction invokes compaction.Compact with the loop's summarization config,
|
||||
// falling back to the primary Stream/Model when the summary-specific fields are
|
||||
// unset. Compact derives the cut point from settings.KeepRecentTokens.
|
||||
func runCompaction(ctx context.Context, msgs agentcore.MessageList, cfg *RunConfig) (*compaction.CompactionResult, error) {
|
||||
stream := cfg.SummaryStream
|
||||
if stream == nil {
|
||||
stream = cfg.Stream
|
||||
}
|
||||
model := cfg.SummaryModel
|
||||
if model.ID == "" {
|
||||
model = provider.Model{Provider: cfg.Provider, ID: cfg.Model, ContextWindow: cfg.ContextWindow}
|
||||
}
|
||||
// Resolve the API key the same way the primary turn does (dynamic key wins,
|
||||
// static APIKey is the fallback) so the summarization stream authenticates
|
||||
// against auth-requiring providers instead of failing with "missing API key".
|
||||
key := cfg.APIKey
|
||||
if cfg.GetAPIKey != nil {
|
||||
if dyn := cfg.GetAPIKey(ctx, cfg.Provider); dyn != "" {
|
||||
key = dyn
|
||||
}
|
||||
}
|
||||
scfg := provider.StreamConfig{APIKey: key, ThinkingLevel: cfg.ThinkingLevel}
|
||||
return compaction.Compact(ctx, stream, model, msgs, cfg.Compaction, -1, nil, "", scfg)
|
||||
}
|
||||
|
||||
// writeCompactionCheckpoint persists the just-produced compaction summary as a
|
||||
// session checkpoint so a later run can reload the collapsed prefix instead of
|
||||
// replaying it (#480/#481). It is a no-op unless checkpoint persistence is wired
|
||||
// (MemoryRoot and SessionID both set) — which is how memory.enabled=false keeps
|
||||
// the whole subsystem inert. It reuses res.Summary (no extra summarization call)
|
||||
// via BuildCheckpoint, tagging the checkpoint with the compaction cut point as
|
||||
// its watermark. All failures are non-fatal: they are logged to stderr and the
|
||||
// run continues on the compacted context (WriteCheckpoint's log-and-continue
|
||||
// contract).
|
||||
func writeCompactionCheckpoint(ctx context.Context, msgs agentcore.MessageList, res *compaction.CompactionResult, cfg *RunConfig) {
|
||||
if cfg.MemoryRoot == "" || cfg.SessionID == "" || res == nil {
|
||||
return
|
||||
}
|
||||
watermark := res.FirstKeptIndex
|
||||
if watermark < 0 {
|
||||
watermark = 0
|
||||
}
|
||||
if watermark > len(msgs) {
|
||||
watermark = len(msgs)
|
||||
}
|
||||
// summarize returns the summary the compaction already computed, so
|
||||
// BuildCheckpoint records an honest CoveredMessages count without a second
|
||||
// LLM round-trip.
|
||||
summarize := func(context.Context, []agentcore.Message) (string, error) {
|
||||
return res.Summary, nil
|
||||
}
|
||||
cp, err := BuildCheckpoint(ctx, msgs[:watermark], watermark, time.Now(), summarize)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "pigo: checkpoint: build for session %s: %v\n", cfg.SessionID, err)
|
||||
return
|
||||
}
|
||||
if err := WriteCheckpoint(cfg.SessionID, cfg.MemoryRoot, cp); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "pigo: checkpoint: write for session %s: %v\n", cfg.SessionID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// applyTurnUpdate applies a non-nil TurnUpdate to the mutable loop state: any
|
||||
// set field replaces the current context / config value for the next turn.
|
||||
func applyTurnUpdate(agentCtx *agentcore.AgentContext, cfg *RunConfig, upd *TurnUpdate) {
|
||||
if upd.Messages != nil {
|
||||
agentCtx.Messages = *upd.Messages
|
||||
}
|
||||
if upd.SystemPrompt != nil {
|
||||
agentCtx.SystemPrompt = *upd.SystemPrompt
|
||||
}
|
||||
if upd.Tools != nil {
|
||||
agentCtx.Tools = *upd.Tools
|
||||
}
|
||||
if upd.Model != nil {
|
||||
cfg.Model = *upd.Model
|
||||
}
|
||||
if upd.ThinkingLevel != nil {
|
||||
cfg.ThinkingLevel = *upd.ThinkingLevel
|
||||
}
|
||||
}
|
||||
|
||||
// failToolCallsFromTruncatedMessage produces an error tool-result message for
|
||||
// every tool call in a truncated (stopReason=length) assistant message, telling
|
||||
// the model the response was cut off and to resend. The results are appended to
|
||||
// the context and returned. Mirrors pi's failToolCallsFromTruncatedMessage.
|
||||
func failToolCallsFromTruncatedMessage(agentCtx *agentcore.AgentContext, assistant agentcore.AssistantMessage) []agentcore.ToolResultMessage {
|
||||
calls := assistant.ToolCalls()
|
||||
if len(calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
results := make([]agentcore.ToolResultMessage, 0, len(calls))
|
||||
for _, c := range calls {
|
||||
results = append(results, agentcore.ToolResultMessage{
|
||||
RoleField: agentcore.RoleToolResult,
|
||||
ToolCallID: c.ID,
|
||||
ToolName: c.Name,
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(
|
||||
"The previous response was truncated because it hit the output token limit, " +
|
||||
"so this tool call was not executed. Please send a shorter response and retry.")},
|
||||
IsError: true,
|
||||
})
|
||||
}
|
||||
for _, r := range results {
|
||||
agentCtx.Messages = append(agentCtx.Messages, r)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// toAgentToolCalls converts the assistant message's ToolCallContent blocks into
|
||||
// the loop-level AgentToolCall view executeToolCalls consumes.
|
||||
func toAgentToolCalls(blocks []agentcore.ToolCallContent) []agentcore.AgentToolCall {
|
||||
if len(blocks) == 0 {
|
||||
return nil
|
||||
}
|
||||
calls := make([]agentcore.AgentToolCall, len(blocks))
|
||||
for i, b := range blocks {
|
||||
calls[i] = agentcore.AgentToolCall{ID: b.ID, Name: b.Name, Arguments: b.Arguments}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
Reference in New Issue
Block a user