Files
zero/internal/tools/goal.go
Anandan 5d1869e7bc feat: add persistent session goals (#803)
* feat: add persistent session goals

* Fix goal run isolation and guards

* Strengthen loop goal isolation test

* Cover goal edit state transitions

* fix(goal): bound automatic continuations

Persist a provider-independent continuation limit, require approval for model-created goals, and keep resume, BTW, and loop runs from starting or charging unrelated goal work.

Also reject trailing lifecycle arguments and bound persisted objective length.

* fix(goal): resume continuations after btw
2026-07-27 13:22:09 +05:30

188 lines
6.1 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/Gitlawb/zero/internal/sessions"
)
type goalTool struct {
baseTool
store *sessions.Store
sessionID string
action string
}
// NewGoalTools returns goal lifecycle tools bound to one captured session. The
// binding prevents a late tool call from mutating a different session after a
// resume or session switch.
func NewGoalTools(store *sessions.Store, sessionID string) []Tool {
return []Tool{
newGetGoalTool(store, sessionID),
newCreateGoalTool(store, sessionID),
newUpdateGoalTool(store, sessionID),
}
}
func newGetGoalTool(store *sessions.Store, sessionID string) Tool {
return &goalTool{
baseTool: baseTool{
name: "get_goal",
description: "Get the current persistent goal and its status, token budget, and usage for this session.",
parameters: Schema{
Type: "object",
AdditionalProperties: false,
},
safety: readOnlySafety("Reads goal state for the current session."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true},
},
store: store,
sessionID: sessionID,
action: "get",
}
}
func newCreateGoalTool(store *sessions.Store, sessionID string) Tool {
minimum := 0
maximum := 1_000_000_000
maxObjectiveLength := sessions.GoalObjectiveMaxLength
return &goalTool{
baseTool: baseTool{
name: "create_goal",
description: "Create one persistent goal for this session when the user explicitly asks to pursue an ongoing objective. " +
"Do not infer a goal from an ordinary one-turn task. This fails while any goal is still stored.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"objective": {
Type: "string",
Description: "The concrete objective to keep pursuing.",
MaxLength: &maxObjectiveLength,
},
"token_budget": {
Type: "integer",
Description: "Optional maximum total tokens. Zero means no goal-specific limit.",
Minimum: &minimum,
Maximum: &maximum,
},
},
Required: []string{"objective"},
AdditionalProperties: false,
},
safety: Safety{
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
Reason: "Creates persistent goal state and enables automatic follow-up runs for the current session.",
AdvertiseInAuto: true,
},
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
store: store,
sessionID: sessionID,
action: "create",
}
}
func newUpdateGoalTool(store *sessions.Store, sessionID string) Tool {
return &goalTool{
baseTool: baseTool{
name: "update_goal",
description: "Finish the current goal or mark it blocked. Use complete only when the objective is achieved and no required work remains. " +
"Use blocked only when progress cannot continue without user input or an external state change.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"status": {
Type: "string",
Description: "The terminal state to apply.",
Enum: []string{string(sessions.GoalStatusComplete), string(sessions.GoalStatusBlocked)},
},
"reason": {
Type: "string",
Description: "Why the goal is blocked. Optional for completed goals.",
},
},
Required: []string{"status"},
AdditionalProperties: false,
},
safety: Safety{
SideEffect: SideEffectNone,
Permission: PermissionAllow,
Reason: "Updates persistent goal state for the current session.",
},
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
store: store,
sessionID: sessionID,
action: "update",
}
}
func (tool *goalTool) Run(_ context.Context, args map[string]any) Result {
if tool.store == nil || !sessions.ValidSessionID(tool.sessionID) {
return errorResult("Error: Goal state is unavailable for this run.")
}
switch tool.action {
case "get":
session, err := tool.store.Get(tool.sessionID)
if err != nil {
return errorResult("Error: Read goal: " + err.Error())
}
if session == nil || session.Goal == nil {
return okResult("No goal is set for this session.")
}
return goalResult(session.Goal)
case "create":
objective, err := stringArg(args, "objective", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for create_goal: " + err.Error())
}
tokenBudget, err := intArg(args, "token_budget", 0, 0, 1_000_000_000)
if err != nil {
return errorResult("Error: Invalid arguments for create_goal: " + err.Error())
}
session, _, err := tool.store.CreateGoal(tool.sessionID, objective, tokenBudget)
if err != nil {
return errorResult("Error: Create goal: " + err.Error())
}
return goalResult(session.Goal)
case "update":
statusText, err := stringArg(args, "status", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for update_goal: " + err.Error())
}
status := sessions.GoalStatus(strings.ToLower(statusText))
if status != sessions.GoalStatusComplete && status != sessions.GoalStatusBlocked {
return errorResult(`Error: Invalid arguments for update_goal: status must be "complete" or "blocked"`)
}
reason, err := stringArgWithEmpty(args, "reason", "", false, true)
if err != nil {
return errorResult("Error: Invalid arguments for update_goal: " + err.Error())
}
if status == sessions.GoalStatusBlocked && strings.TrimSpace(reason) == "" {
return errorResult("Error: Invalid arguments for update_goal: blocked goals require a reason")
}
session, _, err := tool.store.UpdateGoal(tool.sessionID, status, reason)
if err != nil {
return errorResult("Error: Update goal: " + err.Error())
}
return goalResult(session.Goal)
default:
return errorResult("Error: Unknown goal action.")
}
}
func goalResult(goal *sessions.Goal) Result {
if goal == nil {
return okResult("No goal is set for this session.")
}
data, err := json.MarshalIndent(goal, "", " ")
if err != nil {
return errorResult(fmt.Sprintf("Error: Encode goal: %v", err))
}
return okResult(string(data))
}