Merge d386df4e2d into 6e6293e596
This commit is contained in:
commit
ca09bd6863
9 changed files with 959 additions and 123 deletions
|
|
@ -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).
|
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
|
||||||
|
|
||||||
Routing is configured through `agents.dispatch.rules`.
|
Routing is configured through `agents.dispatch.rules`.
|
||||||
|
|
|
||||||
203
pkg/agent/action_summary.go
Normal file
203
pkg/agent/action_summary.go
Normal 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
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
|
@ -484,6 +485,10 @@ toolLoop:
|
||||||
toolResult = tools.ErrorResult("hook returned nil tool result")
|
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 {
|
if len(toolResult.Media) > 0 && toolResult.ResponseHandled {
|
||||||
parts := make([]bus.MediaPart, 0, len(toolResult.Media))
|
parts := make([]bus.MediaPart, 0, len(toolResult.Media))
|
||||||
for _, ref := range toolResult.Media {
|
for _, ref := range toolResult.Media {
|
||||||
|
|
@ -678,6 +683,16 @@ toolLoop:
|
||||||
}
|
}
|
||||||
|
|
||||||
// No pending steering: finalize or break depending on allResponsesHandled
|
// 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 {
|
if exec.allResponsesHandled {
|
||||||
summaryMsg := providers.Message{
|
summaryMsg := providers.Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
|
|
|
||||||
|
|
@ -474,7 +474,9 @@ func (p *Pipeline) CallLLM(
|
||||||
if responseContent == "" && exec.response.ReasoningContent != "" && ts.channel != "pico" {
|
if responseContent == "" && exec.response.ReasoningContent != "" && ts.channel != "pico" {
|
||||||
responseContent = exec.response.ReasoningContent
|
responseContent = exec.response.ReasoningContent
|
||||||
}
|
}
|
||||||
|
exec.actionLog = appendTurnActionRecord(exec.actionLog, "assistant_direct", "", responseContent, false)
|
||||||
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
|
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
|
||||||
|
exec.markSteeringObserved()
|
||||||
logger.InfoCF("agent", "Steering arrived after direct LLM response; continuing turn",
|
logger.InfoCF("agent", "Steering arrived after direct LLM response; continuing turn",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": ts.agent.ID,
|
"agent_id": ts.agent.ID,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"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)
|
turnCtx, turnCancel := context.WithCancel(ctx)
|
||||||
defer turnCancel()
|
defer turnCancel()
|
||||||
ts.setTurnCancel(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
|
// We do NOT call dequeueSteeringMessagesForScope here because
|
||||||
// steering was already consumed from al.steering by ExecuteTools.
|
// steering was already consumed from al.steering by ExecuteTools.
|
||||||
if len(exec.pendingMessages) > 0 {
|
if len(exec.pendingMessages) > 0 {
|
||||||
|
exec.markSteeringObserved()
|
||||||
pendingMessages = append(pendingMessages, exec.pendingMessages...)
|
pendingMessages = append(pendingMessages, exec.pendingMessages...)
|
||||||
exec.pendingMessages = nil
|
exec.pendingMessages = nil
|
||||||
}
|
}
|
||||||
} else if !ts.opts.SkipInitialSteeringPoll {
|
} else if !ts.opts.SkipInitialSteeringPoll {
|
||||||
if steerMsgs := al.dequeueSteeringMessagesForScopeWithFallback(ts.sessionKey); len(steerMsgs) > 0 {
|
if steerMsgs := al.dequeueSteeringMessagesForScopeWithFallback(ts.sessionKey); len(steerMsgs) > 0 {
|
||||||
|
exec.markSteeringObserved()
|
||||||
pendingMessages = append(pendingMessages, steerMsgs...)
|
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)
|
// Check if parent turn has ended (SubTurn support from HEAD)
|
||||||
if ts.parentTurnState != nil && ts.IsParentEnded() {
|
if ts.parentTurnState != nil && ts.IsParentEnded() {
|
||||||
if !ts.critical {
|
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,
|
"agent_id": ts.agentID,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"turn_id": ts.turnID,
|
"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)
|
// Poll for pending SubTurn results (from HEAD)
|
||||||
|
|
@ -200,6 +214,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
|
||||||
if finalContent == "" {
|
if finalContent == "" {
|
||||||
finalContent = ts.opts.DefaultResponse
|
finalContent = ts.opts.DefaultResponse
|
||||||
}
|
}
|
||||||
|
finalContent = renderFinalTurnReply(turnCtx, al, ts, exec, finalContent)
|
||||||
return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
|
return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
|
||||||
case ControlToolLoop:
|
case ControlToolLoop:
|
||||||
// Execute tools via Pipeline
|
// 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
|
// (added tool results/skipped messages) before returning ControlContinue
|
||||||
messages = exec.messages
|
messages = exec.messages
|
||||||
continue
|
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:
|
case ToolControlBreak:
|
||||||
// Hard abort: delegate to abortTurn (sets TurnEndStatusAborted)
|
// Hard abort: delegate to abortTurn (sets TurnEndStatusAborted)
|
||||||
if exec.abortedByHardAbort {
|
if exec.abortedByHardAbort {
|
||||||
|
|
@ -227,6 +272,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
|
||||||
if exec.allResponsesHandled {
|
if exec.allResponsesHandled {
|
||||||
finalContent = ""
|
finalContent = ""
|
||||||
}
|
}
|
||||||
|
finalContent = renderFinalTurnReply(turnCtx, al, ts, exec, finalContent)
|
||||||
return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, 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 = ts.opts.DefaultResponse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finalContent = renderFinalTurnReply(turnCtx, al, ts, exec, finalContent)
|
||||||
|
|
||||||
// Check hard abort before finalizing (may have been set during tool execution)
|
// Check hard abort before finalizing (may have been set during tool execution)
|
||||||
if ts.hardAbortRequested() {
|
if ts.hardAbortRequested() {
|
||||||
|
|
@ -299,7 +346,10 @@ func (al *AgentLoop) selectCandidates(
|
||||||
"score": score,
|
"score": score,
|
||||||
"threshold": agent.Router.Threshold(),
|
"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 {
|
func (al *AgentLoop) resolveContextManager() ContextManager {
|
||||||
|
|
@ -316,10 +366,14 @@ func (al *AgentLoop) resolveContextManager() ContextManager {
|
||||||
}
|
}
|
||||||
cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al)
|
cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{
|
logger.WarnCF(
|
||||||
"name": name,
|
"agent",
|
||||||
"error": err.Error(),
|
"Failed to create context manager, falling back to legacy",
|
||||||
})
|
map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"error": err.Error(),
|
||||||
|
},
|
||||||
|
)
|
||||||
return &legacyContextManager{al: al}
|
return &legacyContextManager{al: al}
|
||||||
}
|
}
|
||||||
return cm
|
return cm
|
||||||
|
|
@ -399,7 +453,11 @@ func (al *AgentLoop) askSideQuestion(
|
||||||
forceModel bool,
|
forceModel bool,
|
||||||
callMessages []providers.Message,
|
callMessages []providers.Message,
|
||||||
) (*providers.LLMResponse, error) {
|
) (*providers.LLMResponse, error) {
|
||||||
provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(agent, selectedModelName, candidate)
|
provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(
|
||||||
|
agent,
|
||||||
|
selectedModelName,
|
||||||
|
candidate,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -419,7 +477,11 @@ func (al *AgentLoop) askSideQuestion(
|
||||||
|
|
||||||
turnCtx := newTurnContext(nil, nil, nil)
|
turnCtx := newTurnContext(nil, nil, nil)
|
||||||
if opts != 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
|
llmModel := activeModel
|
||||||
if al.hooks != nil {
|
if al.hooks != nil {
|
||||||
|
|
@ -475,7 +537,8 @@ func (al *AgentLoop) askSideQuestion(
|
||||||
func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) {
|
func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) {
|
||||||
candidate := providers.FallbackCandidate{Provider: providerName, Model: model}
|
candidate := providers.FallbackCandidate{Provider: providerName, Model: model}
|
||||||
for _, activeCandidate := range activeCandidates {
|
for _, activeCandidate := range activeCandidates {
|
||||||
if activeCandidate.Provider == providerName && activeCandidate.Model == model {
|
if activeCandidate.Provider == providerName &&
|
||||||
|
activeCandidate.Model == model {
|
||||||
candidate = activeCandidate
|
candidate = activeCandidate
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -563,7 +626,9 @@ func (al *AgentLoop) isolatedSideQuestionProvider(
|
||||||
candidate providers.FallbackCandidate,
|
candidate providers.FallbackCandidate,
|
||||||
) (providers.LLMProvider, string, func(), error) {
|
) (providers.LLMProvider, string, func(), error) {
|
||||||
if agent == nil {
|
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)
|
modelCfg, err := al.sideQuestionModelConfig(agent, baseModelName, candidate)
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,8 @@ type turnExecution struct {
|
||||||
|
|
||||||
// Turn output
|
// Turn output
|
||||||
finalContent string
|
finalContent string
|
||||||
|
actionLog []TurnActionRecord
|
||||||
|
sawSteering bool
|
||||||
|
|
||||||
// Iteration tracking
|
// Iteration tracking
|
||||||
iteration int
|
iteration int
|
||||||
|
|
@ -147,6 +149,13 @@ type turnExecution struct {
|
||||||
abortedByHook bool // true when HookActionAbortTurn triggered
|
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.
|
// newTurnExecution creates a turnExecution initialized from turnState and options.
|
||||||
func newTurnExecution(
|
func newTurnExecution(
|
||||||
agent *AgentInstance,
|
agent *AgentInstance,
|
||||||
|
|
@ -160,6 +169,7 @@ func newTurnExecution(
|
||||||
summary: summary,
|
summary: summary,
|
||||||
messages: messages,
|
messages: messages,
|
||||||
pendingMessages: append([]providers.Message(nil), opts.InitialSteeringMessages...),
|
pendingMessages: append([]providers.Message(nil), opts.InitialSteeringMessages...),
|
||||||
|
sawSteering: len(opts.InitialSteeringMessages) > 0,
|
||||||
iteration: 0,
|
iteration: 0,
|
||||||
phase: LLMPhaseSetup,
|
phase: LLMPhaseSetup,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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_"`
|
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
|
||||||
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
|
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
|
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"`
|
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"`
|
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
|
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.
|
// 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.
|
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
|
||||||
func (d *AgentDefaults) GetModelName() string {
|
func (d *AgentDefaults) GetModelName() string {
|
||||||
|
|
@ -1017,7 +1022,11 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
}
|
}
|
||||||
if e := json.Unmarshal(data, &versionInfo); e != nil {
|
if e := json.Unmarshal(data, &versionInfo); e != nil {
|
||||||
e = wrapJSONError(data, e, "config.json")
|
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
|
return nil, e
|
||||||
}
|
}
|
||||||
if len(data) <= 10 {
|
if len(data) <= 10 {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ func DefaultConfig() *Config {
|
||||||
MaxArgsLength: 300,
|
MaxArgsLength: 300,
|
||||||
SeparateMessages: false,
|
SeparateMessages: false,
|
||||||
},
|
},
|
||||||
|
FinalTurnRenderMode: "",
|
||||||
SplitOnMarker: false,
|
SplitOnMarker: false,
|
||||||
MaxLLMRetries: 2,
|
MaxLLMRetries: 2,
|
||||||
LLMRetryBackoffSecs: 2,
|
LLMRetryBackoffSecs: 2,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue