Files
picoclaw/pkg/agent/turn_coord.go
T
Hoshina 795ee362ea refactor(events): emit agent runtime events directly
Remove the legacy EventKind/Event envelope mapping and let agent event emission build pkg/events.Event values directly.

Keep HookMeta as the shared hook metadata shape and preserve legacy observe string aliases by mapping them to runtime event kinds.

Validation: GOCACHE=/tmp/picoclaw-go-cache go test ./pkg/agent; make lint
2026-04-26 16:55:02 +08:00

626 lines
19 KiB
Go

// PicoClaw - Ultra-lightweight personal AI agent
package agent
import (
"context"
"fmt"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipeline) (turnResult, error) {
turnCtx, turnCancel := context.WithCancel(ctx)
defer turnCancel()
ts.setTurnCancel(turnCancel)
// Inject turnState and AgentLoop into context so tools (e.g. spawn) can retrieve them.
turnCtx = withTurnState(turnCtx, ts)
turnCtx = WithAgentLoop(turnCtx, al)
al.registerActiveTurn(ts)
defer al.clearActiveTurn(ts)
turnStatus := TurnEndStatusCompleted
defer func() {
al.emitEvent(
runtimeevents.KindAgentTurnEnd,
ts.eventMeta("runTurn", "turn.end"),
TurnEndPayload{
Status: turnStatus,
Iterations: ts.currentIteration(),
Duration: time.Since(ts.startedAt),
FinalContentLen: ts.finalContentLen(),
},
)
}()
al.emitEvent(
runtimeevents.KindAgentTurnStart,
ts.eventMeta("runTurn", "turn.start"),
TurnStartPayload{
UserMessage: ts.userMessage,
MediaCount: len(ts.media),
},
)
// SetupTurn extracts the one-time initialization phase.
exec, err := pipeline.SetupTurn(turnCtx, ts)
if err != nil {
return turnResult{}, err
}
// Convenience references to exec fields used throughout the turn loop.
messages := exec.messages
pendingMessages := exec.pendingMessages
maxMediaSize := pipeline.Cfg.Agents.Defaults.GetMaxMediaSize()
finalContent := exec.finalContent
for ts.currentIteration() < ts.agent.MaxIterations || len(exec.pendingMessages) > 0 || func() bool {
graceful, _ := ts.gracefulInterruptRequested()
return graceful
}() {
if ts.hardAbortRequested() {
turnStatus = TurnEndStatusAborted
return al.abortTurn(ts)
}
iteration := ts.currentIteration() + 1
ts.setIteration(iteration)
ts.setPhase(TurnPhaseRunning)
if iteration > 1 {
// For subsequent iterations, read from exec.pendingMessages which
// is where ExecuteTools (or initial poll) deposits steering.
// We do NOT call dequeueSteeringMessagesForScope here because
// steering was already consumed from al.steering by ExecuteTools.
if len(exec.pendingMessages) > 0 {
pendingMessages = append(pendingMessages, exec.pendingMessages...)
exec.pendingMessages = nil
}
} else if !ts.opts.SkipInitialSteeringPoll {
if steerMsgs := al.dequeueSteeringMessagesForScopeWithFallback(ts.sessionKey); len(steerMsgs) > 0 {
pendingMessages = append(pendingMessages, steerMsgs...)
}
}
// Check if parent turn has ended (SubTurn support from HEAD)
if ts.parentTurnState != nil && ts.IsParentEnded() {
if !ts.critical {
logger.InfoCF("agent", "Parent turn ended, non-critical SubTurn exiting gracefully", map[string]any{
"agent_id": ts.agentID,
"iteration": iteration,
"turn_id": ts.turnID,
})
break
}
logger.InfoCF("agent", "Parent turn ended, critical SubTurn continues running", map[string]any{
"agent_id": ts.agentID,
"iteration": iteration,
"turn_id": ts.turnID,
})
}
// Poll for pending SubTurn results (from HEAD)
if ts.pendingResults != nil {
select {
case result, ok := <-ts.pendingResults:
if ok && result != nil && result.ForLLM != "" {
content := al.cfg.FilterSensitiveData(result.ForLLM)
msg := subTurnResultPromptMessage(content)
pendingMessages = append(pendingMessages, msg)
}
default:
// No results available
}
}
// Inject pending steering messages
if len(pendingMessages) > 0 {
resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize)
totalContentLen := 0
for i, pm := range pendingMessages {
messages = append(messages, resolvedPending[i])
totalContentLen += len(pm.Content)
if !ts.opts.NoHistory {
ts.agent.Sessions.AddFullMessage(ts.sessionKey, pm)
ts.recordPersistedMessage(pm)
ts.ingestMessage(turnCtx, al, pm)
}
logger.InfoCF("agent", "Injected steering message into context",
map[string]any{
"agent_id": ts.agent.ID,
"iteration": iteration,
"content_len": len(pm.Content),
"media_count": len(pm.Media),
})
}
al.emitEvent(
runtimeevents.KindAgentSteeringInjected,
ts.eventMeta("runTurn", "turn.steering.injected"),
SteeringInjectedPayload{
Count: len(pendingMessages),
TotalContentLen: totalContentLen,
},
)
// Clear exec.pendingMessages after injection so InitialSteeringMessages
// are not re-injected on subsequent iterations (Issue 2 fix).
exec.pendingMessages = nil
}
// Always sync messages into exec.messages so CallLLM sees the updated state
exec.messages = messages
logger.DebugCF("agent", "LLM iteration",
map[string]any{
"agent_id": ts.agent.ID,
"iteration": iteration,
"max": ts.agent.MaxIterations,
})
// Execute LLM call via Pipeline
ts.setPhase(TurnPhaseRunning)
ctrl, callErr := pipeline.CallLLM(ctx, turnCtx, ts, exec, iteration)
if callErr != nil {
turnStatus = TurnEndStatusError
return turnResult{}, callErr
}
messages = exec.messages
pendingMessages = exec.pendingMessages
finalContent = exec.finalContent
switch ctrl {
case ControlContinue:
continue
case ControlBreak:
// Hard abort: delegate to abortTurn (sets TurnEndStatusAborted)
if exec.abortedByHardAbort {
turnStatus = TurnEndStatusAborted
return al.abortTurn(ts)
}
// Hook abort (HookActionAbortTurn): sets TurnEndStatusError, returns error
if exec.abortedByHook {
turnStatus = TurnEndStatusError
return turnResult{}, fmt.Errorf("hook requested turn abort")
}
// Ensure empty response falls back to DefaultResponse
if finalContent == "" {
finalContent = ts.opts.DefaultResponse
}
return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
case ControlToolLoop:
// Execute tools via Pipeline
toolCtrl := pipeline.ExecuteTools(ctx, turnCtx, ts, exec, iteration)
switch toolCtrl {
case ToolControlContinue:
// Re-read exec.messages since ExecuteTools may have updated it
// (added tool results/skipped messages) before returning ControlContinue
messages = exec.messages
continue
case ToolControlBreak:
// Hard abort: delegate to abortTurn (sets TurnEndStatusAborted)
if exec.abortedByHardAbort {
turnStatus = TurnEndStatusAborted
return al.abortTurn(ts)
}
// Hook abort (HookActionAbortTurn): sets TurnEndStatusError, returns error
if exec.abortedByHook {
turnStatus = TurnEndStatusError
return turnResult{}, fmt.Errorf("hook requested turn abort")
}
// ExecuteTools returned ControlBreak:
// - allResponsesHandled=true: finalize without DefaultResponse (exec.finalContent empty)
// - allResponsesHandled=false: coordinator applies DefaultResponse before finalize
if exec.allResponsesHandled {
finalContent = ""
}
return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
}
}
}
if ts.hardAbortRequested() {
turnStatus = TurnEndStatusAborted
return al.abortTurn(ts)
}
if finalContent == "" {
if ts.currentIteration() >= ts.agent.MaxIterations && ts.agent.MaxIterations > 0 {
finalContent = toolLimitResponse
} else {
finalContent = ts.opts.DefaultResponse
}
}
// Check hard abort before finalizing (may have been set during tool execution)
if ts.hardAbortRequested() {
turnStatus = TurnEndStatusAborted
return al.abortTurn(ts)
}
return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
}
func (al *AgentLoop) abortTurn(ts *turnState) (turnResult, error) {
ts.setPhase(TurnPhaseAborted)
if !ts.opts.NoHistory {
if err := ts.restoreSession(ts.agent); err != nil {
al.emitEvent(
runtimeevents.KindAgentError,
ts.eventMeta("abortTurn", "turn.error"),
ErrorPayload{
Stage: "session_restore",
Message: err.Error(),
},
)
return turnResult{}, err
}
}
return turnResult{status: TurnEndStatusAborted}, nil
}
func (al *AgentLoop) selectCandidates(
agent *AgentInstance,
userMsg string,
history []providers.Message,
) (candidates []providers.FallbackCandidate, model string, usedLight bool) {
if agent.Router == nil || len(agent.LightCandidates) == 0 {
return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false
}
_, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model)
if !usedLight {
logger.DebugCF("agent", "Model routing: primary model selected",
map[string]any{
"agent_id": agent.ID,
"score": score,
"threshold": agent.Router.Threshold(),
})
return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false
}
logger.InfoCF("agent", "Model routing: light model selected",
map[string]any{
"agent_id": agent.ID,
"light_model": agent.Router.LightModel(),
"score": score,
"threshold": agent.Router.Threshold(),
})
return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true
}
func (al *AgentLoop) resolveContextManager() ContextManager {
name := al.cfg.Agents.Defaults.ContextManager
if name == "" || name == "legacy" {
return &legacyContextManager{al: al}
}
factory, ok := lookupContextManager(name)
if !ok {
logger.WarnCF("agent", "Unknown context manager, falling back to legacy", map[string]any{
"name": name,
})
return &legacyContextManager{al: al}
}
cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al)
if err != nil {
logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{
"name": name,
"error": err.Error(),
})
return &legacyContextManager{al: al}
}
return cm
}
func (al *AgentLoop) askSideQuestion(
ctx context.Context,
agent *AgentInstance,
opts *processOptions,
question string,
) (string, error) {
if agent == nil {
return "", fmt.Errorf("askSideQuestion: no agent available for /btw")
}
question = strings.TrimSpace(question)
if question == "" {
return "", fmt.Errorf("askSideQuestion: %w", fmt.Errorf("Usage: /btw <question>"))
}
if opts != nil {
normalizeProcessOptionsInPlace(opts)
}
var media []string
var channel, chatID, senderID, senderDisplayName string
if opts != nil {
media = opts.Media
channel = opts.Channel
chatID = opts.ChatID
senderID = opts.SenderID
senderDisplayName = opts.SenderDisplayName
}
// Build messages with context but WITHOUT adding to session history
var history []providers.Message
var summary string
if opts != nil && !opts.NoHistory {
if resp, err := al.contextManager.Assemble(ctx, &AssembleRequest{
SessionKey: opts.SessionKey,
Budget: agent.ContextWindow,
MaxTokens: agent.MaxTokens,
}); err == nil && resp != nil {
history = resp.History
summary = resp.Summary
}
}
messages := agent.ContextBuilder.BuildMessages(
history,
summary,
question,
media,
channel,
chatID,
senderID,
senderDisplayName,
)
maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize()
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages)
selectedModelName := sideQuestionModelName(agent, usedLight)
llmOpts := map[string]any{
"max_tokens": agent.MaxTokens,
"temperature": agent.Temperature,
"prompt_cache_key": agent.ID + ":btw",
}
hookModelChanged := false
callProvider := func(
ctx context.Context,
candidate providers.FallbackCandidate,
model string,
forceModel bool,
callMessages []providers.Message,
) (*providers.LLMResponse, error) {
provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(agent, selectedModelName, candidate)
if err != nil {
return nil, err
}
defer cleanup()
if !forceModel || strings.TrimSpace(model) == "" {
model = providerModel
}
callOpts := llmOpts
if _, exists := callOpts["thinking_level"]; !exists && agent.ThinkingLevel != ThinkingOff {
if tc, ok := provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() {
callOpts = shallowCloneLLMOptions(llmOpts)
callOpts["thinking_level"] = string(agent.ThinkingLevel)
}
}
return provider.Chat(ctx, callMessages, nil, model, callOpts)
}
turnCtx := newTurnContext(nil, nil, nil)
if opts != nil {
turnCtx = newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope)
}
llmModel := activeModel
if al.hooks != nil {
llmReq, decision := al.hooks.BeforeLLM(ctx, &LLMHookRequest{
Meta: HookMeta{
Source: "askSideQuestion",
TracePath: "turn.llm.request",
turnContext: cloneTurnContext(turnCtx),
},
Context: cloneTurnContext(turnCtx),
Model: llmModel,
Messages: messages,
Tools: nil,
Options: llmOpts,
GracefulTerminal: false,
})
switch decision.normalizedAction() {
case HookActionContinue, HookActionModify:
if llmReq != nil {
if strings.TrimSpace(llmReq.Model) != "" && llmReq.Model != llmModel {
hookModelChanged = true
}
llmModel = llmReq.Model
messages = llmReq.Messages
llmOpts = llmReq.Options
}
case HookActionAbortTurn:
reason := decision.Reason
if reason == "" {
reason = "hook requested turn abort"
}
return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason)
case HookActionHardAbort:
reason := decision.Reason
if reason == "" {
reason = "hook requested turn abort"
}
return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason)
}
}
if hookModelChanged {
// Hook-selected models must not continue through the pre-hook fallback
// candidate list, otherwise fallback execution would call the original
// candidate model and silently ignore the hook decision.
activeCandidates = nil
}
callSideLLM := func(callMessages []providers.Message) (*providers.LLMResponse, error) {
if len(activeCandidates) > 1 && al.fallback != nil {
fbResult, err := al.fallback.Execute(
ctx,
activeCandidates,
func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) {
candidate := providers.FallbackCandidate{Provider: providerName, Model: model}
for _, activeCandidate := range activeCandidates {
if activeCandidate.Provider == providerName && activeCandidate.Model == model {
candidate = activeCandidate
break
}
}
return callProvider(ctx, candidate, model, false, callMessages)
},
)
if err != nil {
return nil, err
}
return fbResult.Response, nil
}
var candidate providers.FallbackCandidate
if len(activeCandidates) > 0 {
candidate = activeCandidates[0]
}
return callProvider(ctx, candidate, llmModel, hookModelChanged, callMessages)
}
// Retry without media if vision is unsupported
// Note: Vision retry is only applied to the initial call. If fallback chain
// is used, vision errors from fallback providers will not trigger retry.
var resp *providers.LLMResponse
var err error
resp, err = callSideLLM(messages)
if err != nil && hasMediaRefs(messages) && isVisionUnsupportedError(err) {
al.emitEvent(
runtimeevents.KindAgentLLMRetry,
HookMeta{
Source: "askSideQuestion",
TracePath: "turn.llm.retry",
turnContext: cloneTurnContext(turnCtx),
},
LLMRetryPayload{
Attempt: 1,
MaxRetries: 1,
Reason: "vision_unsupported",
Error: err.Error(),
Backoff: 0,
},
)
messagesWithoutMedia := stripMessageMedia(messages)
resp, err = callSideLLM(messagesWithoutMedia)
}
if err != nil {
return "", err
}
if resp == nil {
return "", nil
}
// Apply after_llm hooks
if al.hooks != nil {
llmResp, decision := al.hooks.AfterLLM(ctx, &LLMHookResponse{
Meta: HookMeta{
Source: "askSideQuestion",
TracePath: "turn.llm.response",
turnContext: cloneTurnContext(turnCtx),
},
Context: cloneTurnContext(turnCtx),
Model: llmModel,
Response: resp,
})
switch decision.normalizedAction() {
case HookActionContinue, HookActionModify:
if llmResp != nil && llmResp.Response != nil {
resp = llmResp.Response
}
case HookActionAbortTurn, HookActionHardAbort:
reason := decision.Reason
if reason == "" {
reason = "hook requested turn abort"
}
return "", fmt.Errorf("hook aborted turn during after_llm: %s", reason)
}
}
return sideQuestionResponseContent(resp), nil
}
func (al *AgentLoop) isolatedSideQuestionProvider(
agent *AgentInstance,
baseModelName string,
candidate providers.FallbackCandidate,
) (providers.LLMProvider, string, func(), error) {
if agent == nil {
return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: no agent available for /btw")
}
modelCfg, err := al.sideQuestionModelConfig(agent, baseModelName, candidate)
if err != nil {
return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err)
}
factory := al.providerFactory
if factory == nil {
factory = providers.CreateProviderFromConfig
}
provider, modelID, err := factory(modelCfg)
if err != nil {
return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err)
}
cleanup := func() {
closeProviderIfStateful(provider)
}
return provider, modelID, cleanup, nil
}
func (al *AgentLoop) sideQuestionModelConfig(
agent *AgentInstance,
baseModelName string,
candidate providers.FallbackCandidate,
) (*config.ModelConfig, error) {
if agent == nil {
return nil, fmt.Errorf("sideQuestionModelConfig: no agent available for /btw")
}
// If candidate has an identity key, use that
if name := modelNameFromIdentityKey(candidate.IdentityKey); name != "" {
modelCfg, err := resolvedModelConfig(al.GetConfig(), name, agent.Workspace)
if err == nil {
return modelCfg, nil
}
// Fallback: create a minimal config if lookup fails
}
// Otherwise, clean up the base model name and use it
baseModelName = strings.TrimSpace(baseModelName)
modelCfg, err := resolvedModelConfig(al.GetConfig(), baseModelName, agent.Workspace)
if err != nil {
// Fallback: create a minimal config for test scenarios
model := strings.TrimSpace(baseModelName)
if candidate.Model != "" {
model = candidate.Model
}
if candidate.Provider != "" && candidate.Model != "" {
model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model
} else {
model = ensureProtocolModel(model)
}
return &config.ModelConfig{
ModelName: baseModelName,
Model: model,
Workspace: agent.Workspace,
}, nil
}
// If candidate specifies a different provider/model, override
clone := *modelCfg
if candidate.Provider != "" && candidate.Model != "" {
clone.Model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model
}
return &clone, nil
}