e071084ebe
govulncheck / govulncheck (push) Waiting to run
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
919 lines
28 KiB
Go
919 lines
28 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"go-micro.dev/v6/ai"
|
|
codecBytes "go-micro.dev/v6/codec/bytes"
|
|
"go-micro.dev/v6/gateway/a2a"
|
|
"go-micro.dev/v6/store"
|
|
"go-micro.dev/v6/wrapper/x402"
|
|
)
|
|
|
|
// Built-in agent tools. These are not service endpoints — they are
|
|
// capabilities the agent has over itself: maintaining a plan in its
|
|
// memory, and delegating a subtask to another agent.
|
|
//
|
|
// They are plain tools, wired into the agent's tool handler alongside
|
|
// the discovered service tools. There is no separate harness or graph:
|
|
// the LLM calls them like any other tool.
|
|
const (
|
|
toolPlan = "plan"
|
|
toolDelegate = "delegate"
|
|
toolHumanInput = "request_input"
|
|
)
|
|
|
|
type delegateCall struct {
|
|
done chan struct{}
|
|
res ai.ToolResult
|
|
}
|
|
|
|
// builtinTools returns the tool definitions exposed to the model in
|
|
// addition to the agent's scoped service tools.
|
|
func builtinTools() []ai.Tool {
|
|
return []ai.Tool{
|
|
{
|
|
Name: toolPlan,
|
|
OriginalName: toolPlan,
|
|
Description: "Record or update your plan as an ordered list of steps before doing multi-step work. " +
|
|
"Call this whenever the plan changes. The plan is saved to your memory and shown back to you on later turns.",
|
|
Properties: map[string]any{
|
|
"steps": map[string]any{
|
|
"type": "array",
|
|
"description": "Ordered plan steps. Each step has a 'task' (string) and a " +
|
|
"'status' (one of: pending, in_progress, done).",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: toolHumanInput,
|
|
OriginalName: toolHumanInput,
|
|
Description: "Pause this agent run when you need missing information, a decision, or other human input before you can continue. " +
|
|
"The run is checkpointed as input-required and can be resumed with the human response without losing completed tool history.",
|
|
Properties: map[string]any{
|
|
"prompt": map[string]any{
|
|
"type": "string",
|
|
"description": "The specific question, decision, or instruction needed from the human operator.",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: toolDelegate,
|
|
OriginalName: toolDelegate,
|
|
Description: "Delegate a self-contained subtask to another agent. If 'to' names an agent that already " +
|
|
"manages the relevant services, that agent handles it; otherwise a focused sub-agent is created for the " +
|
|
"subtask. The sub-agent works in an isolated context and returns only its result. Use this to keep your " +
|
|
"own context focused and to let domain experts handle their own services.",
|
|
Properties: map[string]any{
|
|
"task": map[string]any{
|
|
"type": "string",
|
|
"description": "The subtask to delegate, described completely and self-contained.",
|
|
},
|
|
"to": map[string]any{
|
|
"type": "string",
|
|
"description": "Optional. The agent or service name best suited to the subtask, or the URL of an external agent that speaks the A2A protocol.",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Builtins returns the built-in agent tools (plan, delegate) together
|
|
// with a handler for them, so the same capabilities can be wired into a
|
|
// tool loop that isn't a running Agent — for example the `micro chat`
|
|
// fallback. The handler's third return value is false when the name is
|
|
// not a built-in, so callers can fall through to their own tools.
|
|
//
|
|
// Configure it with the same options as an Agent (Name, Provider,
|
|
// WithStore, WithRegistry, WithClient, ...); these back plan's memory
|
|
// and delegate's RPC/sub-agent behavior.
|
|
func Builtins(opts ...Option) (tools []ai.Tool, handle func(name string, input map[string]any) (result any, content string, ok bool)) {
|
|
a := &agentImpl{opts: newOptions(opts...)}
|
|
handle = func(name string, input map[string]any) (any, string, bool) {
|
|
switch name {
|
|
case toolPlan:
|
|
r := a.handlePlan(ai.ToolCall{Name: name, Input: input})
|
|
return r.Value, r.Content, true
|
|
case toolHumanInput:
|
|
r := a.handleHumanInput(ai.ToolCall{Name: name, Input: input})
|
|
return r.Value, r.Content, true
|
|
case toolDelegate:
|
|
r := a.handleDelegate(context.Background(), ai.ToolCall{Name: name, Input: input})
|
|
return r.Value, r.Content, true
|
|
}
|
|
return nil, "", false
|
|
}
|
|
return builtinTools(), handle
|
|
}
|
|
|
|
// toolHandler returns the agent's tool-call handler, composed as a stack
|
|
// of wrappers around a base handler — the same middleware shape as
|
|
// client/server wrappers. The base executes the call (custom tools,
|
|
// delegate, or RPC); the built-in guardrails wrap it; developer wrappers
|
|
// (WrapTool) wrap those, outermost, so they observe every call and its
|
|
// result including guardrail refusals. Ephemeral sub-agents get the bare
|
|
// service handler so they can neither plan nor re-delegate (which
|
|
// prevents runaway recursion).
|
|
func (a *agentImpl) toolHandler() ai.ToolHandler {
|
|
if a.ephemeral {
|
|
return a.toolTimeoutWrap(a.tools.Handler())
|
|
}
|
|
|
|
// Innermost first: base, then guardrails (approve → loop → step →
|
|
// plan), then developer wrappers outermost. Wrapping reverses order,
|
|
// so the result runs plan → step → loop → approve → checkpoint → base.
|
|
h := a.baseHandler()
|
|
h = a.toolTimeoutWrap(h)
|
|
h = a.x402PayWrap(h)
|
|
h = a.toolRetryWrap(h)
|
|
h = a.checkpointToolWrap(h)
|
|
h = a.approveWrap(h)
|
|
h = a.spendWrap(h)
|
|
h = a.loopWrap(h)
|
|
h = a.stepWrap(h)
|
|
h = a.planWrap(h)
|
|
h = contextWrap(h)
|
|
h = a.traceTool(h)
|
|
for i := len(a.opts.wrappers) - 1; i >= 0; i-- {
|
|
h = a.opts.wrappers[i](h)
|
|
}
|
|
return h
|
|
}
|
|
|
|
// contextWrap stops tool execution promptly when the Ask context has
|
|
// already been canceled or its deadline has expired. This keeps guardrail
|
|
// bookkeeping and side-effecting tools from running after the caller has
|
|
// abandoned the agent run.
|
|
func contextWrap(next ai.ToolHandler) ai.ToolHandler {
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
select {
|
|
case <-ctx.Done():
|
|
return errResult(call.ID, ctx.Err().Error())
|
|
default:
|
|
}
|
|
return next(ctx, call)
|
|
}
|
|
}
|
|
|
|
// toolTimeoutWrap gives each tool execution its own deadline while preserving
|
|
// caller cancellation. Handlers still execute synchronously; tools that honor
|
|
// context (custom tools, delegate RPC/A2A, and go-micro RPC clients) return
|
|
// promptly with a bounded error result when the deadline expires.
|
|
func (a *agentImpl) toolTimeoutWrap(next ai.ToolHandler) ai.ToolHandler {
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
if a.opts.ToolTimeout <= 0 {
|
|
return next(ctx, call)
|
|
}
|
|
toolCtx, cancel := context.WithTimeout(ctx, a.opts.ToolTimeout)
|
|
defer cancel()
|
|
return next(toolCtx, call)
|
|
}
|
|
}
|
|
|
|
// x402PayWrap pays an x402 Payment Required tool result and retries the
|
|
// underlying HTTP tool once. Tools that proxy HTTP paid resources can return the
|
|
// raw x402 402 challenge body and include a "url" input; the agent then uses
|
|
// wrapper/x402.Client so payer and budget semantics stay in one place.
|
|
func (a *agentImpl) x402PayWrap(next ai.ToolHandler) ai.ToolHandler {
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
res := next(ctx, call)
|
|
if res.Refused != "" || !isX402Challenge(res.Content) {
|
|
return res
|
|
}
|
|
url, _ := call.Input["url"].(string)
|
|
if url == "" {
|
|
return errResult(call.ID, "x402: payment required but tool result did not include a retryable url input")
|
|
}
|
|
budget := a.opts.Budget
|
|
if budget > 0 {
|
|
remaining := budget - a.spend
|
|
if remaining <= 0 {
|
|
return refused(call.ID, ai.RefusedSpendBudget, fmt.Sprintf(
|
|
"x402 spend budget exceeded: no budget remaining for %s (spent %d of %d)",
|
|
call.Name, a.spend, budget))
|
|
}
|
|
budget = remaining
|
|
}
|
|
client := &x402.Client{Payer: a.opts.Payer, Budget: budget}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return errResult(call.ID, err.Error())
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "would exceed budget") {
|
|
return refused(call.ID, ai.RefusedSpendBudget, err.Error())
|
|
}
|
|
return errResult(call.ID, err.Error())
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return errResult(call.ID, err.Error())
|
|
}
|
|
a.spend += client.Spent()
|
|
var value any
|
|
if err := json.Unmarshal(body, &value); err != nil {
|
|
value = string(body)
|
|
}
|
|
return ai.ToolResult{ID: call.ID, Value: value, Content: string(body), Attempts: 2}
|
|
}
|
|
}
|
|
|
|
func isX402Challenge(content string) bool {
|
|
var ch struct {
|
|
X402Version int `json:"x402Version"`
|
|
Accepts []x402.Requirements `json:"accepts"`
|
|
}
|
|
return json.Unmarshal([]byte(content), &ch) == nil && ch.X402Version > 0 && len(ch.Accepts) > 0
|
|
}
|
|
|
|
// toolRetryWrap retries transient tool failures with bounded backoff. It is
|
|
// opt-in because tools can have side effects; guardrail refusals and caller
|
|
// cancellation are never retried.
|
|
func (a *agentImpl) toolRetryWrap(next ai.ToolHandler) ai.ToolHandler {
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
maxAttempts := a.opts.ToolMaxAttempts
|
|
if maxAttempts <= 0 {
|
|
maxAttempts = 1
|
|
}
|
|
|
|
var res ai.ToolResult
|
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return errResult(call.ID, err.Error())
|
|
}
|
|
res = next(ctx, call)
|
|
if !retryableToolResult(res) || attempt == maxAttempts || ctx.Err() != nil {
|
|
return annotateToolAttempts(res, attempt)
|
|
}
|
|
|
|
t := time.NewTimer(toolRetryBackoff(attempt, a.opts.ToolRetryBackoff))
|
|
select {
|
|
case <-ctx.Done():
|
|
if !t.Stop() {
|
|
<-t.C
|
|
}
|
|
return errResult(call.ID, ctx.Err().Error())
|
|
case <-t.C:
|
|
}
|
|
}
|
|
return annotateToolAttempts(res, maxAttempts)
|
|
}
|
|
}
|
|
|
|
func retryableToolResult(res ai.ToolResult) bool {
|
|
if res.Refused != "" {
|
|
return false
|
|
}
|
|
msg := toolErrorMessage(res)
|
|
if msg == "" {
|
|
return false
|
|
}
|
|
return ai.IsTransientError(fmt.Errorf("%s", msg))
|
|
}
|
|
|
|
func toolErrorMessage(res ai.ToolResult) string {
|
|
if m, ok := res.Value.(map[string]string); ok {
|
|
return m["error"]
|
|
}
|
|
if m, ok := res.Value.(map[string]any); ok {
|
|
if v, ok := m["error"].(string); ok {
|
|
return v
|
|
}
|
|
}
|
|
var decoded map[string]string
|
|
if err := json.Unmarshal([]byte(res.Content), &decoded); err == nil {
|
|
return decoded["error"]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func annotateToolAttempts(res ai.ToolResult, attempts int) ai.ToolResult {
|
|
if attempts <= 1 {
|
|
return res
|
|
}
|
|
res.Attempts = attempts
|
|
if m, ok := res.Value.(map[string]string); ok {
|
|
cp := map[string]any{}
|
|
for k, v := range m {
|
|
cp[k] = v
|
|
}
|
|
cp["attempts"] = attempts
|
|
res.Value = cp
|
|
if b, err := json.Marshal(cp); err == nil {
|
|
res.Content = string(b)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
func toolRetryBackoff(attempt int, base time.Duration) time.Duration {
|
|
if base <= 0 {
|
|
base = 200 * time.Millisecond
|
|
}
|
|
if shift := attempt - 1; shift > 0 {
|
|
base <<= shift
|
|
}
|
|
if base > 30*time.Second {
|
|
return 30 * time.Second
|
|
}
|
|
return base
|
|
}
|
|
|
|
// baseHandler executes a tool call: a developer custom tool, the built-in
|
|
// delegate, or an RPC to the service. It is the innermost handler.
|
|
func (a *agentImpl) baseHandler() ai.ToolHandler {
|
|
rpc := a.tools.Handler()
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
for i := range a.opts.tools {
|
|
if a.opts.tools[i].def.Name == call.Name {
|
|
out, err := a.opts.tools[i].handler(ctx, call.Input)
|
|
if err != nil {
|
|
return errResult(call.ID, err.Error())
|
|
}
|
|
return ai.ToolResult{ID: call.ID, Value: out, Content: out}
|
|
}
|
|
}
|
|
if call.Name == toolHumanInput {
|
|
return a.handleHumanInput(call)
|
|
}
|
|
if call.Name == toolDelegate {
|
|
return a.handleDelegate(ctx, call)
|
|
}
|
|
return rpc(ctx, call)
|
|
}
|
|
}
|
|
|
|
// planWrap handles the plan tool inline. plan is internal bookkeeping,
|
|
// not an action — it is never counted, loop-checked, or gated.
|
|
func (a *agentImpl) planWrap(next ai.ToolHandler) ai.ToolHandler {
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
if call.Name == toolPlan {
|
|
return a.handlePlan(call)
|
|
}
|
|
if containsNestedTextToolCall(call.Input) {
|
|
return refused(call.ID, ai.RefusedApproval, "malformed tool call: nested text tool-call markup found inside arguments; call the intended tool directly with clean JSON arguments")
|
|
}
|
|
if call.Name == toolDelegate {
|
|
if blocked := a.unfinishedPlanStepsBeforeDelegation(); len(blocked) > 0 {
|
|
return refused(call.ID, ai.RefusedApproval, "complete these plan steps before delegating: "+strings.Join(blocked, ", "))
|
|
}
|
|
}
|
|
res := next(ctx, call)
|
|
if res.Refused == "" && toolErrorMessage(res) == "" {
|
|
a.completeNextPlanStep()
|
|
}
|
|
return res
|
|
}
|
|
}
|
|
|
|
// stepWrap bounds the number of actions per Ask (MaxSteps).
|
|
func (a *agentImpl) stepWrap(next ai.ToolHandler) ai.ToolHandler {
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
if a.opts.MaxSteps > 0 {
|
|
a.steps++
|
|
if a.steps > a.opts.MaxSteps {
|
|
return refused(call.ID, ai.RefusedMaxSteps, fmt.Sprintf(
|
|
"step limit reached (%d). Do not call any more tools; stop and summarize what you have so far.",
|
|
a.opts.MaxSteps))
|
|
}
|
|
}
|
|
return next(ctx, call)
|
|
}
|
|
}
|
|
|
|
// loopWrap stops the agent repeating an identical action that makes no
|
|
// progress (which the step count alone won't catch).
|
|
func (a *agentImpl) loopWrap(next ai.ToolHandler) ai.ToolHandler {
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
if a.opts.LoopLimit > 0 {
|
|
if a.calls == nil {
|
|
a.calls = map[string]int{}
|
|
}
|
|
args, _ := json.Marshal(call.Input)
|
|
fp := call.Name + ":" + string(args)
|
|
a.calls[fp]++
|
|
if a.calls[fp] > a.opts.LoopLimit {
|
|
return refused(call.ID, ai.RefusedLoop, fmt.Sprintf(
|
|
"loop detected: you have already called %q with the same arguments %d times and the result will not change. Stop repeating it — try a different approach, or finish with what you have.",
|
|
call.Name, a.opts.LoopLimit))
|
|
}
|
|
}
|
|
return next(ctx, call)
|
|
}
|
|
}
|
|
|
|
// approveWrap gates each action before it runs (ApproveTool).
|
|
type approvalPause struct {
|
|
Tool string
|
|
Message string
|
|
}
|
|
|
|
type inputPause struct {
|
|
OriginalMessage string `json:"original_message"`
|
|
Prompt string `json:"prompt"`
|
|
}
|
|
|
|
func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
if a.opts.Approve != nil {
|
|
if ok, reason := a.opts.Approve(call.Name, call.Input); !ok {
|
|
msg := "tool call was not approved"
|
|
if reason != "" {
|
|
msg += ": " + reason
|
|
}
|
|
a.pause = &approvalPause{Tool: call.Name, Message: msg}
|
|
return refused(call.ID, ai.RefusedApproval, msg)
|
|
}
|
|
}
|
|
return next(ctx, call)
|
|
}
|
|
}
|
|
|
|
// spendWrap reserves a per-run x402 spend budget before paid tool execution.
|
|
func (a *agentImpl) spendWrap(next ai.ToolHandler) ai.ToolHandler {
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
amount := a.opts.ToolSpend[call.Name]
|
|
if amount <= 0 || a.opts.MaxSpend <= 0 {
|
|
return next(ctx, call)
|
|
}
|
|
if a.spend+amount > a.opts.MaxSpend {
|
|
return refused(call.ID, ai.RefusedSpendBudget, fmt.Sprintf(
|
|
"x402 spend budget exceeded: paying %d for %s would exceed per-run budget (spent %d of %d)",
|
|
amount, call.Name, a.spend, a.opts.MaxSpend))
|
|
}
|
|
a.spend += amount
|
|
if info, ok := ai.RunInfoFrom(ctx); ok {
|
|
info.Spent = a.spend
|
|
info.ToolSpend = amount
|
|
ctx = ai.WithRunInfo(ctx, info)
|
|
}
|
|
res := next(ctx, call)
|
|
if res.Refused != "" || toolErrorMessage(res) != "" {
|
|
a.spend -= amount
|
|
}
|
|
return res
|
|
}
|
|
}
|
|
|
|
// handlePlan persists the supplied plan to the agent's memory and
|
|
// echoes it back so the model can see the stored state.
|
|
func (a *agentImpl) handlePlan(call ai.ToolCall) ai.ToolResult {
|
|
input := preserveCompletedPlanSteps(a.loadPlan(), call.Input)
|
|
data, err := json.Marshal(input)
|
|
if err != nil {
|
|
return errResult(call.ID, "invalid plan: "+err.Error())
|
|
}
|
|
_ = a.stateStore().Write(&store.Record{Key: planKey, Value: data})
|
|
return ai.ToolResult{ID: call.ID, Value: input, Content: string(data)}
|
|
}
|
|
|
|
func preserveCompletedPlanSteps(stored string, input map[string]any) map[string]any {
|
|
if stored == "" {
|
|
return input
|
|
}
|
|
var previous map[string]any
|
|
if err := json.Unmarshal([]byte(stored), &previous); err != nil {
|
|
return input
|
|
}
|
|
completed := completedPlanTasks(previous)
|
|
if len(completed) == 0 {
|
|
return input
|
|
}
|
|
steps, ok := input["steps"].([]any)
|
|
if !ok {
|
|
return input
|
|
}
|
|
for _, raw := range steps {
|
|
step, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
task, _ := step["task"].(string)
|
|
if completed[planTaskCompletionKey(task)] && isUnfinishedPlanStatus(step["status"]) {
|
|
step["status"] = "done"
|
|
}
|
|
}
|
|
return input
|
|
}
|
|
|
|
func completedPlanTasks(plan map[string]any) map[string]bool {
|
|
steps, ok := plan["steps"].([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
completed := map[string]bool{}
|
|
for _, raw := range steps {
|
|
step, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
status, _ := step["status"].(string)
|
|
if status != "done" {
|
|
continue
|
|
}
|
|
task, _ := step["task"].(string)
|
|
if task = planTaskCompletionKey(task); task != "" {
|
|
completed[task] = true
|
|
}
|
|
}
|
|
return completed
|
|
}
|
|
|
|
func normalizePlanTask(task string) string {
|
|
return strings.Join(strings.Fields(strings.ToLower(task)), " ")
|
|
}
|
|
|
|
func planTaskCompletionKey(task string) string {
|
|
normalized := normalizePlanTask(task)
|
|
if normalized == "" {
|
|
return ""
|
|
}
|
|
if isLaunchReadinessDelegationPlanTask(normalized) {
|
|
return "launch-readiness-notification"
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func isLaunchReadinessDelegationPlanTask(task string) bool {
|
|
task = normalizePlanTask(task)
|
|
if !strings.Contains(task, "notify") && !strings.Contains(task, "notification") {
|
|
return false
|
|
}
|
|
hasLaunchReadiness := strings.Contains(task, "launch") || strings.Contains(task, "readiness") || strings.Contains(task, "ready")
|
|
hasOwnerComms := strings.Contains(task, "owner") && strings.Contains(task, "comms")
|
|
return hasLaunchReadiness || hasOwnerComms
|
|
}
|
|
|
|
func isUnfinishedPlanStatus(status any) bool {
|
|
s, _ := status.(string)
|
|
return s == "" || s == "pending" || s == "in_progress"
|
|
}
|
|
|
|
func (a *agentImpl) completeNextPlanStep() {
|
|
plan := a.loadPlan()
|
|
if plan == "" {
|
|
return
|
|
}
|
|
var data map[string]any
|
|
if err := json.Unmarshal([]byte(plan), &data); err != nil {
|
|
return
|
|
}
|
|
steps, ok := data["steps"].([]any)
|
|
if !ok {
|
|
return
|
|
}
|
|
for _, raw := range steps {
|
|
step, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
status, _ := step["status"].(string)
|
|
if status == "" || status == "pending" || status == "in_progress" {
|
|
step["status"] = "done"
|
|
b, err := json.Marshal(data)
|
|
if err == nil {
|
|
_ = a.stateStore().Write(&store.Record{Key: planKey, Value: b})
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *agentImpl) unfinishedPlanStepsBeforeDelegation() []string {
|
|
plan := a.loadPlan()
|
|
if plan == "" {
|
|
return nil
|
|
}
|
|
var data map[string]any
|
|
if err := json.Unmarshal([]byte(plan), &data); err != nil {
|
|
return nil
|
|
}
|
|
steps, ok := data["steps"].([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var unfinished []string
|
|
for _, raw := range steps {
|
|
step, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
task := planStepTask(step)
|
|
if isDelegationPlanTask(task) {
|
|
break
|
|
}
|
|
if !isUnfinishedPlanStatus(step["status"]) {
|
|
continue
|
|
}
|
|
if task == "" {
|
|
task = "<unnamed>"
|
|
}
|
|
unfinished = append(unfinished, task)
|
|
}
|
|
return unfinished
|
|
}
|
|
|
|
func planStepTask(step map[string]any) string {
|
|
if task, _ := step["task"].(string); task != "" {
|
|
return task
|
|
}
|
|
desc, _ := step["description"].(string)
|
|
return desc
|
|
}
|
|
|
|
func isDelegationPlanTask(task string) bool {
|
|
task = normalizePlanTask(task)
|
|
return strings.Contains(task, "delegate") || strings.Contains(task, "notify") || strings.Contains(task, "notification")
|
|
}
|
|
|
|
func (a *agentImpl) unfinishedPlanSteps() []string {
|
|
plan := a.loadPlan()
|
|
if plan == "" {
|
|
return nil
|
|
}
|
|
var data map[string]any
|
|
if err := json.Unmarshal([]byte(plan), &data); err != nil {
|
|
return nil
|
|
}
|
|
steps, ok := data["steps"].([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var unfinished []string
|
|
for _, raw := range steps {
|
|
step, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
status, _ := step["status"].(string)
|
|
if status != "" && status != "pending" && status != "in_progress" {
|
|
continue
|
|
}
|
|
task := planStepTask(step)
|
|
if task == "" {
|
|
task = "<unnamed>"
|
|
}
|
|
unfinished = append(unfinished, task)
|
|
}
|
|
return unfinished
|
|
}
|
|
|
|
// handleHumanInput records that the model needs operator input before it can continue.
|
|
func (a *agentImpl) handleHumanInput(call ai.ToolCall) ai.ToolResult {
|
|
prompt, _ := call.Input["prompt"].(string)
|
|
prompt = strings.TrimSpace(prompt)
|
|
if prompt == "" {
|
|
prompt = "human input required"
|
|
}
|
|
a.pause = &approvalPause{Tool: toolHumanInput, Message: prompt}
|
|
return refused(call.ID, ai.RefusedApproval, "input-required: "+prompt)
|
|
}
|
|
|
|
// handleDelegate hands a subtask to another agent. Delegate-first:
|
|
// if 'to' names a registered agent, it is called via RPC. Otherwise an
|
|
// ephemeral sub-agent is created with a fresh, isolated context, asked
|
|
// the subtask, and its reply returned.
|
|
func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res ai.ToolResult) {
|
|
input := call.Input
|
|
task, _ := input["task"].(string)
|
|
if task == "" {
|
|
return errResult(call.ID, "task is required")
|
|
}
|
|
to, _ := input["to"].(string)
|
|
if cached, ok := a.cachedDelegateResult(call.ID, to, task); ok {
|
|
return cached
|
|
}
|
|
|
|
key := delegateResultKey(to, task)
|
|
if cached, ok := a.joinDelegateCall(ctx, call.ID, key); ok {
|
|
return cached
|
|
}
|
|
defer func() { a.finishDelegateCall(key, res) }()
|
|
|
|
// An external agent on another framework, addressed by A2A URL.
|
|
if strings.HasPrefix(to, "http://") || strings.HasPrefix(to, "https://") {
|
|
reply, err := a2a.NewClient(to).Send(ctx, task)
|
|
if err != nil {
|
|
return errResult(call.ID, "delegate to A2A agent "+to+": "+err.Error())
|
|
}
|
|
return a.storeDelegateResult(call.ID, to, task, map[string]any{"agent": to, "reply": reply})
|
|
}
|
|
|
|
// Delegate-first: an existing agent that owns the domain handles it.
|
|
if to != "" && a.isAgent(to) {
|
|
reply, err := a.callAgentRPC(ctx, to, task)
|
|
if err != nil {
|
|
return errResult(call.ID, "delegate to agent "+to+": "+err.Error())
|
|
}
|
|
return a.storeDelegateResult(call.ID, to, task, map[string]any{"agent": to, "reply": reply})
|
|
}
|
|
|
|
// Otherwise create a focused, ephemeral sub-agent. Fresh context:
|
|
// it loads no history and persists none.
|
|
var svcs []string
|
|
if to != "" {
|
|
svcs = []string{to}
|
|
}
|
|
sub := newEphemeral(
|
|
Name(a.opts.Name+".sub"),
|
|
Services(svcs...),
|
|
Prompt("You are a sub-agent handling a single delegated subtask. "+
|
|
"Complete it using the available tools and report the result concisely."),
|
|
Provider(a.opts.Provider),
|
|
Model(a.opts.Model),
|
|
APIKey(a.opts.APIKey),
|
|
WithRegistry(a.opts.Registry),
|
|
WithClient(a.opts.Client),
|
|
WithStore(a.opts.Store),
|
|
ModelCallTimeout(a.opts.ModelTimeout),
|
|
ModelRetry(a.opts.ModelMaxAttempts, a.opts.ModelRetryBackoff),
|
|
ToolCallTimeout(a.opts.ToolTimeout),
|
|
ToolRetry(a.opts.ToolMaxAttempts, a.opts.ToolRetryBackoff),
|
|
TraceProvider(a.opts.TraceProvider),
|
|
)
|
|
// Record lineage so the sub-agent's tool calls carry this run as parent.
|
|
sub.parentRunID = a.runID
|
|
|
|
resp, err := sub.Ask(ctx, task)
|
|
if err != nil {
|
|
return errResult(call.ID, "sub-agent: "+err.Error())
|
|
}
|
|
return a.storeDelegateResult(call.ID, to, task, map[string]any{"reply": resp.Reply})
|
|
}
|
|
|
|
func (a *agentImpl) joinDelegateCall(ctx context.Context, id, key string) (ai.ToolResult, bool) {
|
|
a.delegateMu.Lock()
|
|
if a.delegateCalls == nil {
|
|
a.delegateCalls = map[string]*delegateCall{}
|
|
}
|
|
if inFlight := a.delegateCalls[key]; inFlight != nil {
|
|
a.delegateMu.Unlock()
|
|
select {
|
|
case <-ctx.Done():
|
|
return errResult(id, ctx.Err().Error()), true
|
|
case <-inFlight.done:
|
|
return withToolResultID(inFlight.res, id), true
|
|
}
|
|
}
|
|
a.delegateCalls[key] = &delegateCall{done: make(chan struct{})}
|
|
a.delegateMu.Unlock()
|
|
return ai.ToolResult{}, false
|
|
}
|
|
|
|
func (a *agentImpl) finishDelegateCall(key string, res ai.ToolResult) {
|
|
a.delegateMu.Lock()
|
|
inFlight := a.delegateCalls[key]
|
|
if inFlight == nil {
|
|
a.delegateMu.Unlock()
|
|
return
|
|
}
|
|
inFlight.res = res
|
|
delete(a.delegateCalls, key)
|
|
close(inFlight.done)
|
|
a.delegateMu.Unlock()
|
|
}
|
|
|
|
func (a *agentImpl) cachedDelegateResult(id, to, task string) (ai.ToolResult, bool) {
|
|
recs, err := a.stateStore().Read(delegateResultKey(to, task))
|
|
if err != nil || len(recs) == 0 {
|
|
return ai.ToolResult{}, false
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(recs[0].Value, &out); err != nil {
|
|
return ai.ToolResult{}, false
|
|
}
|
|
b, _ := json.Marshal(out)
|
|
return ai.ToolResult{ID: id, Value: out, Content: string(b)}, true
|
|
}
|
|
|
|
func (a *agentImpl) storeDelegateResult(id, to, task string, out map[string]any) ai.ToolResult {
|
|
b, _ := json.Marshal(out)
|
|
_ = a.stateStore().Write(&store.Record{Key: delegateResultKey(to, task), Value: b})
|
|
return ai.ToolResult{ID: id, Value: out, Content: string(b)}
|
|
}
|
|
|
|
func withToolResultID(res ai.ToolResult, id string) ai.ToolResult {
|
|
res.ID = id
|
|
return res
|
|
}
|
|
|
|
func delegateResultKey(to, task string) string {
|
|
fp := normalizeDelegateTarget(to) + "\x00" + normalizeDelegateTask(task)
|
|
sum := sha256.Sum256([]byte(fp))
|
|
return fmt.Sprintf("delegate/%x", sum)
|
|
}
|
|
|
|
func normalizeDelegateTarget(to string) string {
|
|
return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(to))), " ")
|
|
}
|
|
|
|
func normalizeDelegateTask(task string) string {
|
|
task = strings.ToLower(strings.TrimSpace(task))
|
|
task = strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
|
return r
|
|
case r == '@':
|
|
return r
|
|
default:
|
|
return ' '
|
|
}
|
|
}, task)
|
|
task = strings.Join(strings.Fields(task), " ")
|
|
if strings.Contains(task, "owner") &&
|
|
strings.Contains(task, "acme") &&
|
|
isLaunchReadinessDelegateTask(task) {
|
|
return "notify owner@acme.com launch-plan-ready"
|
|
}
|
|
return task
|
|
}
|
|
|
|
func isLaunchReadinessDelegateTask(task string) bool {
|
|
hasNotify := strings.Contains(task, "notify") || strings.Contains(task, "notification") || strings.Contains(task, "tell")
|
|
hasLaunch := strings.Contains(task, "launch")
|
|
hasPlanOrReadiness := strings.Contains(task, "plan") || strings.Contains(task, "readiness") || strings.Contains(task, "ready")
|
|
hasCompletion := strings.Contains(task, "ready") ||
|
|
strings.Contains(task, "readiness") ||
|
|
strings.Contains(task, "prepared") ||
|
|
strings.Contains(task, "complete") ||
|
|
strings.Contains(task, "finished") ||
|
|
strings.Contains(task, "done") ||
|
|
strings.Contains(task, "sent")
|
|
return hasNotify && hasLaunch && hasPlanOrReadiness && hasCompletion
|
|
}
|
|
|
|
// isAgent reports whether name resolves to a registered agent (a
|
|
// service advertising type=agent in its metadata).
|
|
func (a *agentImpl) isAgent(name string) bool {
|
|
if a.opts.Registry == nil {
|
|
return false
|
|
}
|
|
recs, err := a.opts.Registry.GetService(name)
|
|
if err != nil || len(recs) == 0 {
|
|
return false
|
|
}
|
|
if recs[0].Metadata != nil && recs[0].Metadata["type"] == "agent" {
|
|
return true
|
|
}
|
|
for _, n := range recs[0].Nodes {
|
|
if n.Metadata != nil && n.Metadata["type"] == "agent" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// callAgentRPC calls another agent's Agent.Chat endpoint and returns
|
|
// its reply.
|
|
func (a *agentImpl) callAgentRPC(ctx context.Context, name, msg string) (string, error) {
|
|
body, _ := json.Marshal(map[string]string{"message": msg})
|
|
req := a.opts.Client.NewRequest(name, "Agent.Chat", &codecBytes.Frame{Data: body})
|
|
var rsp codecBytes.Frame
|
|
if err := a.opts.Client.Call(ctx, req, &rsp); err != nil {
|
|
return "", err
|
|
}
|
|
var out struct {
|
|
Reply string `json:"reply"`
|
|
}
|
|
if err := json.Unmarshal(rsp.Data, &out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.Reply, nil
|
|
}
|
|
|
|
// planKey is the record key for an agent's plan within its scoped store.
|
|
const planKey = "plan"
|
|
|
|
// loadPlan returns the stored plan as a JSON string, or "" if none.
|
|
func (a *agentImpl) loadPlan() string {
|
|
recs, err := a.stateStore().Read(planKey)
|
|
if err != nil || len(recs) == 0 {
|
|
return ""
|
|
}
|
|
return string(recs[0].Value)
|
|
}
|
|
|
|
func errResult(id, msg string) ai.ToolResult {
|
|
m := map[string]string{"error": msg}
|
|
b, _ := json.Marshal(m)
|
|
return ai.ToolResult{ID: id, Value: m, Content: string(b)}
|
|
}
|
|
|
|
// refused is an error result a guardrail returns, tagged with a structured
|
|
// reason (ai.Refused*) so a tool wrapper can react to it without parsing
|
|
// the message.
|
|
func refused(id, reason, msg string) ai.ToolResult {
|
|
r := errResult(id, msg)
|
|
r.Refused = reason
|
|
return r
|
|
}
|