feat: Introduce VerifyTool and 'Plan-Act-Verify' guidance, enhance tool call argument parsing, and refine agent summarization logic.

This commit is contained in:
Rahul Chand 2026-02-20 16:29:43 +05:30
parent 2c980c33a8
commit c473304373
10 changed files with 357 additions and 29 deletions

View file

@ -76,11 +76,16 @@ Your workspace is at: %s
## Important Rules ## Important Rules
1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it. 1. **Plan-Act-Verify** - Use a structured approach for all tasks:
- **Plan**: Briefly state what you intend to do before using any tool.
- **Act**: Execute the tool call.
- **Verify**: After seeing the result, explicitly evaluate if the goal was achieved before moving to the next step.
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. 2. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`, 3. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
4. **Memory** - When remembering something, write to %s/memory/MEMORY.md`,
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath) now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
} }

View file

@ -30,7 +30,9 @@ type AgentInstance struct {
Tools *tools.ToolRegistry Tools *tools.ToolRegistry
Subagents *config.SubagentsConfig Subagents *config.SubagentsConfig
SkillsFilter []string SkillsFilter []string
Candidates []providers.FallbackCandidate Candidates []providers.FallbackCandidate
SummarizeMessageThreshold int
SummarizeTokenPercentage int
} }
// NewAgentInstance creates an agent instance from config. // NewAgentInstance creates an agent instance from config.
@ -54,6 +56,7 @@ func NewAgentInstance(
toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg)) toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg))
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewVerifyTool(workspace, restrict))
sessionsDir := filepath.Join(workspace, "sessions") sessionsDir := filepath.Join(workspace, "sessions")
sessionsManager := session.NewSessionManager(sessionsDir) sessionsManager := session.NewSessionManager(sessionsDir)
@ -109,9 +112,11 @@ func NewAgentInstance(
Sessions: sessionsManager, Sessions: sessionsManager,
ContextBuilder: contextBuilder, ContextBuilder: contextBuilder,
Tools: toolsRegistry, Tools: toolsRegistry,
Subagents: subagents, Subagents: subagents,
SkillsFilter: skillsFilter, SkillsFilter: skillsFilter,
Candidates: candidates, Candidates: candidates,
SummarizeMessageThreshold: defaults.SummarizeMessageThreshold,
SummarizeTokenPercentage: defaults.SummarizeTokenPercentage,
} }
} }

View file

@ -303,7 +303,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
Channel: msg.Channel, Channel: msg.Channel,
ChatID: msg.ChatID, ChatID: msg.ChatID,
UserMessage: msg.Content, UserMessage: msg.Content,
DefaultResponse: "I've completed processing but have no response to give.", DefaultResponse: "Task completed, but no final summary was generated.",
EnableSummary: true, EnableSummary: true,
SendResponse: false, SendResponse: false,
}) })
@ -638,7 +638,16 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
} }
} }
toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback) // Check if tool call arguments were malformed (fallback to "raw")
var contentForLLM string
var toolResult *tools.ToolResult
if rawArgs, ok := tc.Arguments["raw"].(string); ok && len(tc.Arguments) == 1 {
errorMsg := fmt.Sprintf("Malformed tool call: The arguments were not valid JSON. Received raw string: %q. Please retry with a valid JSON object matching the tool's schema.", rawArgs)
contentForLLM = fmt.Sprintf("Error: %s\n\nReflection: Your previous tool call failed due to syntax errors. Ensure you are providing a valid JSON object for the arguments, without any trailing tokens or text outside the braces.", errorMsg)
goto addMessage
}
toolResult = agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
// Send ForUser content to user immediately if not Silent // Send ForUser content to user immediately if not Silent
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
@ -655,11 +664,18 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
} }
// Determine content for LLM based on tool result // Determine content for LLM based on tool result
contentForLLM := toolResult.ForLLM contentForLLM = toolResult.ForLLM
if contentForLLM == "" && toolResult.Err != nil { if toolResult.Err != nil {
contentForLLM = toolResult.Err.Error() errorMsg := toolResult.Err.Error()
if contentForLLM == "" {
contentForLLM = fmt.Sprintf("Error: %s\n\nReflection: The previous tool call failed. Analyze why it failed and adjust your plan if necessary. If you need to retry with different parameters, do so now.", errorMsg)
} else {
contentForLLM = fmt.Sprintf("%s\n\nError: %s\n\nReflection: The tool execution encountered an issue. Review the output and error above, then decide on the next steps.", contentForLLM, errorMsg)
}
} }
addMessage:
toolResultMsg := providers.Message{ toolResultMsg := providers.Message{
Role: "tool", Role: "tool",
Content: contentForLLM, Content: contentForLLM,
@ -672,6 +688,31 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
} }
} }
// Final Summary Nudge: If we finished but have no content to show the user,
// and we actually did some work (iteration > 1), ask for a summary.
if finalContent == "" && iteration > 1 {
logger.InfoCF("agent", "Empty response detected after tool calls, nudging for summary",
map[string]interface{}{"agent_id": agent.ID, "session_key": opts.SessionKey})
nudgeMsg := providers.Message{
Role: "user",
Content: "You have completed the tool calls. Please provide a concise summary of what you did and the final result for the user.",
}
// Don't append to persistent messages, just for this final call
nudgeMessages := append(messages, nudgeMsg)
// Call LLM one last time without tools
summaryResp, err := agent.Provider.Chat(ctx, nudgeMessages, nil, agent.Model, map[string]interface{}{
"max_tokens": agent.MaxTokens * 2, // Allow a bit more for summary
"temperature": 0.5,
})
if err == nil && summaryResp.Content != "" {
finalContent = summaryResp.Content
// Save the nudge response to session so it's in history
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
}
}
return finalContent, iteration, nil return finalContent, iteration, nil
} }
@ -699,9 +740,20 @@ func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID st
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) { func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
newHistory := agent.Sessions.GetHistory(sessionKey) newHistory := agent.Sessions.GetHistory(sessionKey)
tokenEstimate := al.estimateTokens(newHistory) tokenEstimate := al.estimateTokens(newHistory)
threshold := agent.ContextWindow * 75 / 100
if len(newHistory) > 20 || tokenEstimate > threshold { // Use configurable thresholds with defaults
tokenPercent := agent.SummarizeTokenPercentage
if tokenPercent == 0 {
tokenPercent = 75
}
msgThreshold := agent.SummarizeMessageThreshold
if msgThreshold == 0 {
msgThreshold = 20
}
threshold := agent.ContextWindow * tokenPercent / 100
if len(newHistory) > msgThreshold || tokenEstimate > threshold {
summarizeKey := agent.ID + ":" + sessionKey summarizeKey := agent.ID + ":" + sessionKey
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
go func() { go func() {

View file

@ -148,7 +148,9 @@ type AgentDefaults struct {
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
SummarizeTokenPercentage int `json:"summarize_token_percentage" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENTAGE"`
} }
type ChannelsConfig struct { type ChannelsConfig struct {
@ -329,8 +331,10 @@ func DefaultConfig() *Config {
RestrictToWorkspace: true, RestrictToWorkspace: true,
Provider: "", Provider: "",
Model: "glm-4.7", Model: "glm-4.7",
MaxTokens: 8192, MaxTokens: 8192,
MaxToolIterations: 20, MaxToolIterations: 20,
SummarizeMessageThreshold: 50,
SummarizeTokenPercentage: 85,
}, },
}, },
Channels: ChannelsConfig{ Channels: ChannelsConfig{

View file

@ -160,10 +160,20 @@ func parseResponse(body []byte) (*LLMResponse, error) {
if tc.Function != nil { if tc.Function != nil {
name = tc.Function.Name name = tc.Function.Name
if tc.Function.Arguments != "" { if tc.Function.Arguments != "" {
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { argData := []byte(tc.Function.Arguments)
if err := json.Unmarshal(argData, &arguments); err != nil {
// Attempt to extract the first valid JSON object if it contains junk (e.g. <|call|>)
extracted := extractJSON(tc.Function.Arguments)
if extracted != "" {
if err2 := json.Unmarshal([]byte(extracted), &arguments); err2 == nil {
goto decoded
}
}
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err) log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
arguments["raw"] = tc.Function.Arguments arguments["raw"] = tc.Function.Arguments
} }
decoded:
} }
} }
@ -230,3 +240,48 @@ func asFloat(v interface{}) (float64, bool) {
return 0, false return 0, false
} }
} }
// extractJSON finds the first valid JSON object in a string.
// This is useful when LLMs append junk tokens like <|call|> after the JSON.
func extractJSON(s string) string {
start := strings.Index(s, "{")
if start == -1 {
return ""
}
depth := 0
inString := false
escaped := false
for i := start; i < len(s); i++ {
char := s[i]
if escaped {
escaped = false
continue
}
if char == '\\' {
escaped = true
continue
}
if char == '"' {
inString = !inString
continue
}
if !inString {
if char == '{' {
depth++
} else if char == '}' {
depth--
if depth == 0 {
return s[start : i+1]
}
}
}
}
return ""
}

View file

@ -256,11 +256,17 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
return "" return ""
} }
pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`) // Refined regex to find potential absolute paths while avoiding URLs.
matches := pathPattern.FindAllString(cmd, -1) // It matches strings starting with / or [A-Z]:\ that are preceded by space, quote, or start of line.
pathPattern := regexp.MustCompile(`(^|[\s"'])(/[^\s"']+|[A-Za-z]:\\[^"'\s]+)`)
matches := pathPattern.FindAllStringSubmatch(cmd, -1)
for _, raw := range matches { for _, match := range matches {
p, err := filepath.Abs(raw) if len(match) < 3 {
continue
}
rawPath := match[2]
p, err := filepath.Abs(rawPath)
if err != nil { if err != nil {
continue continue
} }

View file

@ -130,8 +130,13 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider
// Determine content for LLM // Determine content for LLM
contentForLLM := toolResult.ForLLM contentForLLM := toolResult.ForLLM
if contentForLLM == "" && toolResult.Err != nil { if toolResult.Err != nil {
contentForLLM = toolResult.Err.Error() errorMsg := toolResult.Err.Error()
if contentForLLM == "" {
contentForLLM = fmt.Sprintf("Error: %s\n\nReflection: The tool execution failed. Analyze the cause, adjust your approach, and try again if necessary.", errorMsg)
} else {
contentForLLM = fmt.Sprintf("%s\n\nError: %s\n\nReflection: The tool call encountered an issue. Review the output and error, then decide on the next best step.", contentForLLM, errorMsg)
}
} }
// Add tool result message // Add tool result message

146
pkg/tools/verify.go Normal file
View file

@ -0,0 +1,146 @@
package tools
import (
"bytes"
"context"
"fmt"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
)
type VerifyTool struct {
workspace string
restrict bool
denyPatterns []*regexp.Regexp
}
func NewVerifyTool(workspace string, restrict bool) *VerifyTool {
return &VerifyTool{
workspace: workspace,
restrict: restrict,
denyPatterns: defaultDenyPatterns, // Reusing from shell.go (they are in the same package)
}
}
func (t *VerifyTool) Name() string {
return "verify"
}
func (t *VerifyTool) Description() string {
return "Verify the results of your work by running a check command (e.g., 'go test', 'build'). Use this to ensure your changes didn't break anything."
}
func (t *VerifyTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"command": map[string]interface{}{
"type": "string",
"description": "The verification command to run",
},
"label": map[string]interface{}{
"type": "string",
"description": "A short label for the verification step (e.g., 'Run unit tests')",
},
},
"required": []string{"command"},
}
}
func (t *VerifyTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
command, ok := args["command"].(string)
if !ok {
return ErrorResult("command is required")
}
label, _ := args["label"].(string)
if label == "" {
label = "Verification"
}
// Safety check (reusing logic from shell.go)
if guardError := t.guardCommand(command, t.workspace); guardError != "" {
return ErrorResult(guardError)
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", command)
} else {
cmd = exec.CommandContext(ctx, "sh", "-c", command)
}
cmd.Dir = t.workspace
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
outputStr := stdout.String()
if stderr.Len() > 0 {
outputStr += "\nSTDERR:\n" + stderr.String()
}
if err != nil {
return &ToolResult{
Err: fmt.Errorf("%s failed: %w", label, err),
ForLLM: fmt.Sprintf("%s FAILED\n\nOutput:\n%s", label, outputStr),
ForUser: fmt.Sprintf("❌ %s failed.\n```\n%s\n```", label, outputStr),
}
}
return &ToolResult{
ForLLM: fmt.Sprintf("%s PASSED\n\nOutput:\n%s", label, outputStr),
ForUser: fmt.Sprintf("✅ %s passed successfully.", label),
}
}
func (t *VerifyTool) guardCommand(command, cwd string) string {
cmdText := strings.TrimSpace(command)
lower := strings.ToLower(cmdText)
for _, pattern := range t.denyPatterns {
if pattern.MatchString(lower) {
return "Command blocked by safety guard (dangerous pattern detected)"
}
}
if t.restrict {
if strings.Contains(cmdText, "..\\") || strings.Contains(cmdText, "../") {
return "Command blocked by safety guard (path traversal detected)"
}
cwdPath, err := filepath.Abs(cwd)
if err != nil {
return ""
}
pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`)
matches := pathPattern.FindAllString(cmdText, -1)
for _, raw := range matches {
p, err := filepath.Abs(raw)
if err != nil {
continue
}
rel, err := filepath.Rel(cwdPath, p)
if err != nil {
continue
}
if strings.HasPrefix(rel, "..") {
return "Command blocked by safety guard (path outside working dir)"
}
}
}
return ""
}

50
pkg/tools/verify_test.go Normal file
View file

@ -0,0 +1,50 @@
package tools
import (
"context"
"testing"
)
func TestVerifyTool(t *testing.T) {
// Create a temp workspace or just use current dir for simple tests
tool := NewVerifyTool(".", false)
t.Run("SuccessCommand", func(t *testing.T) {
ctx := context.Background()
args := map[string]interface{}{
"command": "echo 'ok'",
"label": "Check OK",
}
result := tool.Execute(ctx, args)
if result.IsError {
t.Errorf("Expected success, got error: %v", result.Err)
}
if result.Err != nil {
t.Errorf("Expected nil Err, got: %v", result.Err)
}
})
t.Run("FailureCommand", func(t *testing.T) {
ctx := context.Background()
args := map[string]interface{}{
"command": "exit 1",
"label": "Fail Check",
}
result := tool.Execute(ctx, args)
if result.Err == nil {
t.Error("Expected error for failing command, got nil")
}
})
t.Run("MissingCommand", func(t *testing.T) {
ctx := context.Background()
args := map[string]interface{}{}
result := tool.Execute(ctx, args)
if !result.IsError {
t.Error("Expected error for missing command")
}
})
}

View file

@ -5,13 +5,13 @@ I am picoclaw, a lightweight AI assistant powered by AI.
## Personality ## Personality
- Helpful and friendly - Helpful and friendly
- Concise and to the point - Concise but thorough
- Curious and eager to learn - Curious and eager to learn
- Honest and transparent - Honest, transparent, and self-correcting
## Values ## Values
- Accuracy over speed - Accuracy and verification over speed
- User privacy and safety - User privacy and safety
- Transparency in actions - Transparency in every action
- Continuous improvement - Continuous improvement through reflection