This commit is contained in:
Anton Bogdanovich 2026-05-10 01:34:16 +00:00 committed by GitHub
commit ca09bd6863
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 959 additions and 123 deletions

View file

@ -136,6 +136,41 @@ Session scope controls how much memory is shared between chats, users, threads,
For step-by-step recipes and isolation patterns, see the [Session Guide](session-guide.md).
### Final Turn Render
`agents.defaults.final_turn_render_mode` controls an experimental final-response render pass for steering-heavy turns.
When enabled with value `llm`, PicoClaw may do one extra **same-agent** LLM pass after tool execution has already completed:
- it reuses the accumulated turn context
- it disables tool calling for that final pass
- it asks the same agent to answer the **full accumulated request chain**, not only the latest follow-up
This is intended for multi-message turns such as:
- `How much did I eat today?`
- `And yesterday?`
- `And the day before yesterday?`
Config:
```json
{
"agents": {
"defaults": {
"final_turn_render_mode": "llm"
}
}
}
```
Notes:
- omitted or empty: disabled
- `llm`: enable same-agent final no-tools render for eligible steering-heavy turns
- this setting is experimental and is mainly useful when follow-up messages often extend the same in-flight turn
- this is separate from channel/message delivery behavior; it affects only how the final reply text is rendered
### Routing
Routing is configured through `agents.dispatch.rules`.

203
pkg/agent/action_summary.go Normal file
View file

@ -0,0 +1,203 @@
package agent
import (
"context"
"encoding/json"
"strings"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
type TurnActionRecord struct {
Source string `json:"source"`
Tool string `json:"tool,omitempty"`
Text string `json:"text"`
Error bool `json:"error,omitempty"`
}
func appendTurnActionRecord(
records []TurnActionRecord,
source, tool, text string,
isError bool,
) []TurnActionRecord {
text = strings.TrimSpace(text)
if text == "" {
return records
}
rec := TurnActionRecord{
Source: source,
Tool: strings.TrimSpace(tool),
Text: text,
Error: isError,
}
if n := len(records); n > 0 {
prev := records[n-1]
if prev.Source == rec.Source && prev.Tool == rec.Tool && prev.Text == rec.Text &&
prev.Error == rec.Error {
return records
}
}
return append(records, rec)
}
func finalTurnRenderEligible(al *AgentLoop, exec *turnExecution) bool {
if al == nil || exec == nil {
return false
}
if !al.cfg.Agents.Defaults.UseFinalTurnRender() {
return false
}
return exec.sawSteering
}
func finalTurnRenderModel(ts *turnState, exec *turnExecution) (providers.LLMProvider, string) {
if exec != nil {
if exec.activeProvider != nil && strings.TrimSpace(exec.activeModel) != "" {
return exec.activeProvider, strings.TrimSpace(exec.activeModel)
}
if exec.activeProvider != nil {
return exec.activeProvider, strings.TrimSpace(ts.agent.Model)
}
}
if ts == nil || ts.agent == nil {
return nil, ""
}
return ts.agent.Provider, strings.TrimSpace(ts.agent.Model)
}
func buildFinalTurnRenderInstruction(exec *turnExecution) string {
var b strings.Builder
b.WriteString("Write the final user-facing reply for this already-completed turn.\n")
b.WriteString("Use the same language and general style as the conversation.\n")
b.WriteString("Do not call tools.\n")
b.WriteString(
"Answer the full accumulated user request across this turn, not only the latest follow-up.\n",
)
b.WriteString(
"If a later follow-up clearly corrected, narrowed, or replaced an earlier request, follow the latest clarified intent.\n",
)
b.WriteString(
"If later follow-ups added to earlier requests, include the completed additive results together.\n",
)
b.WriteString(
"Use only the facts already present in the conversation and tool results. Do not invent missing results.\n",
)
b.WriteString("Keep the reply concise and natural.\n")
if exec == nil || len(exec.actionLog) == 0 {
return b.String()
}
records := make([]TurnActionRecord, 0, len(exec.actionLog))
for _, rec := range exec.actionLog {
if strings.TrimSpace(rec.Text) == "" {
continue
}
records = append(records, rec)
}
if len(records) == 0 {
return b.String()
}
raw, err := json.MarshalIndent(records, "", " ")
if err != nil {
return b.String()
}
b.WriteString("\nExplicit user-facing outcomes recorded during the turn:\n")
_, _ = b.Write(raw)
return b.String()
}
func tryRenderFinalTurnReply(
ctx context.Context,
al *AgentLoop,
ts *turnState,
exec *turnExecution,
fallback string,
) (string, bool) {
fallback = strings.TrimSpace(fallback)
if !finalTurnRenderEligible(al, exec) {
return fallback, false
}
if exec == nil || len(exec.messages) == 0 {
return fallback, false
}
provider, model := finalTurnRenderModel(ts, exec)
if provider == nil || model == "" {
return fallback, false
}
messages := append([]providers.Message(nil), exec.messages...)
instruction := buildFinalTurnRenderInstruction(exec)
messages = append(messages, providers.Message{
Role: "user",
Content: instruction,
})
opts := map[string]any{
"max_tokens": minInt(ts.agent.MaxTokens, 800),
"temperature": 0.2,
"prompt_cache_key": ts.agent.ID,
}
resp, err := provider.Chat(ctx, messages, nil, model, opts)
if err != nil || resp == nil {
if err != nil {
logger.WarnCF("agent", "Final turn render pass failed", map[string]any{
"agent_id": ts.agent.ID,
"error": err.Error(),
})
}
return fallback, false
}
content := strings.TrimSpace(resp.Content)
if content == "" {
content = strings.TrimSpace(resp.ReasoningContent)
}
if content == "" {
return fallback, false
}
logger.InfoCF("agent", "Rendered final reply from accumulated turn context",
map[string]any{
"agent_id": ts.agent.ID,
"session_key": ts.sessionKey,
"messages_count": len(messages),
"action_record_count": len(exec.actionLog),
})
return content, true
}
func renderFinalTurnReply(
ctx context.Context,
al *AgentLoop,
ts *turnState,
exec *turnExecution,
fallback string,
) string {
content, ok := tryRenderFinalTurnReply(ctx, al, ts, exec, fallback)
if ok {
return content
}
return strings.TrimSpace(fallback)
}
func shouldFinalizeAfterToolLoopWithRender(al *AgentLoop, exec *turnExecution) bool {
if !finalTurnRenderEligible(al, exec) {
return false
}
if exec == nil {
return false
}
return !exec.allResponsesHandled
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
@ -484,6 +485,10 @@ toolLoop:
toolResult = tools.ErrorResult("hook returned nil tool result")
}
if toolSummary := strings.TrimSpace(toolResult.ForUser); toolSummary != "" {
exec.actionLog = appendTurnActionRecord(exec.actionLog, "tool_result", toolName, toolSummary, toolResult.IsError)
}
if len(toolResult.Media) > 0 && toolResult.ResponseHandled {
parts := make([]bus.MediaPart, 0, len(toolResult.Media))
for _, ref := range toolResult.Media {
@ -678,6 +683,16 @@ toolLoop:
}
// No pending steering: finalize or break depending on allResponsesHandled
if shouldFinalizeAfterToolLoopWithRender(al, exec) {
logger.InfoCF("agent", "Tool loop completed; rendering terminal reply from accumulated turn context",
map[string]any{
"agent_id": ts.agent.ID,
"iteration": iteration,
"tool_count": len(normalizedToolCalls),
})
return ToolControlFinalize
}
if exec.allResponsesHandled {
summaryMsg := providers.Message{
Role: "assistant",

View file

@ -474,7 +474,9 @@ func (p *Pipeline) CallLLM(
if responseContent == "" && exec.response.ReasoningContent != "" && ts.channel != "pico" {
responseContent = exec.response.ReasoningContent
}
exec.actionLog = appendTurnActionRecord(exec.actionLog, "assistant_direct", "", responseContent, false)
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
exec.markSteeringObserved()
logger.InfoCF("agent", "Steering arrived after direct LLM response; continuing turn",
map[string]any{
"agent_id": ts.agent.ID,

View file

@ -14,7 +14,11 @@ import (
"github.com/sipeed/picoclaw/pkg/providers"
)
func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipeline) (turnResult, error) {
func (al *AgentLoop) runTurn(
ctx context.Context,
ts *turnState,
pipeline *Pipeline,
) (turnResult, error) {
turnCtx, turnCancel := context.WithCancel(ctx)
defer turnCancel()
ts.setTurnCancel(turnCancel)
@ -89,11 +93,13 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
// We do NOT call dequeueSteeringMessagesForScope here because
// steering was already consumed from al.steering by ExecuteTools.
if len(exec.pendingMessages) > 0 {
exec.markSteeringObserved()
pendingMessages = append(pendingMessages, exec.pendingMessages...)
exec.pendingMessages = nil
}
} else if !ts.opts.SkipInitialSteeringPoll {
if steerMsgs := al.dequeueSteeringMessagesForScopeWithFallback(ts.sessionKey); len(steerMsgs) > 0 {
exec.markSteeringObserved()
pendingMessages = append(pendingMessages, steerMsgs...)
}
}
@ -101,18 +107,26 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
// 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{
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,
})
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)
@ -200,6 +214,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
if finalContent == "" {
finalContent = ts.opts.DefaultResponse
}
finalContent = renderFinalTurnReply(turnCtx, al, ts, exec, finalContent)
return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
case ControlToolLoop:
// Execute tools via Pipeline
@ -210,6 +225,36 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
// (added tool results/skipped messages) before returning ControlContinue
messages = exec.messages
continue
case ToolControlFinalize:
renderedContent, rendered := tryRenderFinalTurnReply(
turnCtx,
al,
ts,
exec,
finalContent,
)
if !rendered {
messages = exec.messages
continue
}
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(
steerMsgs,
) > 0 {
exec.markSteeringObserved()
logger.InfoCF(
"agent",
"Steering arrived during terminal render; continuing turn",
map[string]any{
"agent_id": ts.agent.ID,
"iteration": iteration,
"steering_count": len(steerMsgs),
},
)
exec.pendingMessages = append(exec.pendingMessages, steerMsgs...)
messages = exec.messages
continue
}
return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, renderedContent)
case ToolControlBreak:
// Hard abort: delegate to abortTurn (sets TurnEndStatusAborted)
if exec.abortedByHardAbort {
@ -227,6 +272,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
if exec.allResponsesHandled {
finalContent = ""
}
finalContent = renderFinalTurnReply(turnCtx, al, ts, exec, finalContent)
return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
}
}
@ -244,6 +290,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
finalContent = ts.opts.DefaultResponse
}
}
finalContent = renderFinalTurnReply(turnCtx, al, ts, exec, finalContent)
// Check hard abort before finalizing (may have been set during tool execution)
if ts.hardAbortRequested() {
@ -299,7 +346,10 @@ func (al *AgentLoop) selectCandidates(
"score": score,
"threshold": agent.Router.Threshold(),
})
return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true
return agent.LightCandidates, resolvedCandidateModel(
agent.LightCandidates,
agent.Router.LightModel(),
), true
}
func (al *AgentLoop) resolveContextManager() ContextManager {
@ -316,10 +366,14 @@ func (al *AgentLoop) resolveContextManager() ContextManager {
}
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(),
})
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
@ -399,7 +453,11 @@ func (al *AgentLoop) askSideQuestion(
forceModel bool,
callMessages []providers.Message,
) (*providers.LLMResponse, error) {
provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(agent, selectedModelName, candidate)
provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(
agent,
selectedModelName,
candidate,
)
if err != nil {
return nil, err
}
@ -419,7 +477,11 @@ func (al *AgentLoop) askSideQuestion(
turnCtx := newTurnContext(nil, nil, nil)
if opts != nil {
turnCtx = newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope)
turnCtx = newTurnContext(
opts.Dispatch.InboundContext,
opts.Dispatch.RouteResult,
opts.Dispatch.SessionScope,
)
}
llmModel := activeModel
if al.hooks != nil {
@ -475,7 +537,8 @@ func (al *AgentLoop) askSideQuestion(
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 {
if activeCandidate.Provider == providerName &&
activeCandidate.Model == model {
candidate = activeCandidate
break
}
@ -563,7 +626,9 @@ func (al *AgentLoop) isolatedSideQuestionProvider(
candidate providers.FallbackCandidate,
) (providers.LLMProvider, string, func(), error) {
if agent == nil {
return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: no agent available for /btw")
return nil, "", func() {}, fmt.Errorf(
"isolatedSideQuestionProvider: no agent available for /btw",
)
}
modelCfg, err := al.sideQuestionModelConfig(agent, baseModelName, candidate)

View file

@ -118,6 +118,8 @@ type turnExecution struct {
// Turn output
finalContent string
actionLog []TurnActionRecord
sawSteering bool
// Iteration tracking
iteration int
@ -147,6 +149,13 @@ type turnExecution struct {
abortedByHook bool // true when HookActionAbortTurn triggered
}
func (e *turnExecution) markSteeringObserved() {
if e == nil {
return
}
e.sawSteering = true
}
// newTurnExecution creates a turnExecution initialized from turnState and options.
func newTurnExecution(
agent *AgentInstance,
@ -160,6 +169,7 @@ func newTurnExecution(
summary: summary,
messages: messages,
pendingMessages: append([]providers.Message(nil), opts.InitialSteeringMessages...),
sawSteering: len(opts.InitialSteeringMessages) > 0,
iteration: 0,
phase: LLMPhaseSetup,
}

View file

@ -275,6 +275,7 @@ type AgentDefaults struct {
MaxParallelTurns int `json:"max_parallel_turns,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS"` // Max concurrent turns (0 or 1 = sequential)
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
FinalTurnRenderMode string `json:"final_turn_render_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_FINAL_TURN_RENDER_MODE"`
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"`
ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"`
@ -311,6 +312,10 @@ func (d *AgentDefaults) IsToolFeedbackSeparateMessagesEnabled() bool {
return d.ToolFeedback.SeparateMessages
}
func (d *AgentDefaults) UseFinalTurnRender() bool {
return strings.EqualFold(strings.TrimSpace(d.FinalTurnRenderMode), "llm")
}
// GetModelName returns the effective model name for the agent defaults.
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *AgentDefaults) GetModelName() string {
@ -1017,7 +1022,11 @@ func LoadConfig(path string) (*Config, error) {
}
if e := json.Unmarshal(data, &versionInfo); e != nil {
e = wrapJSONError(data, e, "config.json")
logger.ErrorCF("config", formatDiagnosticLogMessage("Malformed config file", e), map[string]any{"path": path})
logger.ErrorCF(
"config",
formatDiagnosticLogMessage("Malformed config file", e),
map[string]any{"path": path},
)
return nil, e
}
if len(data) <= 10 {

View file

@ -39,6 +39,7 @@ func DefaultConfig() *Config {
MaxArgsLength: 300,
SeparateMessages: false,
},
FinalTurnRenderMode: "",
SplitOnMarker: false,
MaxLLMRetries: 2,
LLMRetryBackoffSecs: 2,