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
358 lines
10 KiB
Go
358 lines
10 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"go-micro.dev/v6/ai"
|
|
"go-micro.dev/v6/flow"
|
|
)
|
|
|
|
const (
|
|
agentAskStep = "ask"
|
|
agentApprovalStep = "approval"
|
|
agentInputStep = "input-required"
|
|
)
|
|
|
|
func (a *agentImpl) newCheckpointRun(runID, message, parentRunID string, existing *flow.Run) flow.Run {
|
|
now := time.Now()
|
|
run := flow.Run{
|
|
ID: runID,
|
|
ParentID: parentRunID,
|
|
Flow: a.opts.Name,
|
|
State: flow.State{Stage: agentAskStep, Data: []byte(message)},
|
|
Steps: []flow.StepRecord{{Name: agentAskStep, Status: "in_progress"}},
|
|
Status: "running",
|
|
Started: now,
|
|
Updated: now,
|
|
}
|
|
if existing != nil {
|
|
run = *existing
|
|
run.Status = "running"
|
|
run.State.Stage = agentAskStep
|
|
if len(run.Steps) == 0 {
|
|
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
|
|
}
|
|
run.Steps[0].Status = "in_progress"
|
|
run.Steps[0].Error = ""
|
|
run.Steps[0].Result = ""
|
|
}
|
|
return run
|
|
}
|
|
|
|
func (a *agentImpl) saveRun(ctx context.Context, run flow.Run) error {
|
|
if a.opts.Checkpoint == nil {
|
|
return nil
|
|
}
|
|
if err := a.opts.Checkpoint.Save(ctx, run); err != nil {
|
|
return fmt.Errorf("agent %s checkpoint save: %w", a.opts.Name, err)
|
|
}
|
|
if info, ok := ai.RunInfoFrom(ctx); ok {
|
|
stage := run.State.Stage
|
|
if stage == "" && len(run.Steps) > 0 {
|
|
stage = run.Steps[0].Name
|
|
}
|
|
a.recordTimelineEvent(ctx, RunEvent{
|
|
Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent,
|
|
Kind: "checkpoint", Name: stage, Status: run.Status,
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Resume returns the response for a checkpointed agent run. Completed runs are
|
|
// returned from the checkpoint without calling the model or replaying tool
|
|
// calls; failed or in-progress runs continue from the saved input message.
|
|
func Resume(ctx context.Context, ag Agent, runID string) (*Response, error) {
|
|
a, ok := ag.(*agentImpl)
|
|
if !ok {
|
|
return nil, fmt.Errorf("agent resume: unsupported agent implementation %T", ag)
|
|
}
|
|
return a.resume(ctx, runID)
|
|
}
|
|
|
|
func (a *agentImpl) resume(ctx context.Context, runID string) (*Response, error) {
|
|
if a.opts.Checkpoint == nil {
|
|
return nil, fmt.Errorf("agent %s has no checkpoint configured", a.opts.Name)
|
|
}
|
|
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, fmt.Errorf("agent run %s not found", runID)
|
|
}
|
|
if run.Status == "paused" {
|
|
if run.State.Stage == agentInputStep {
|
|
return nil, fmt.Errorf("agent run %s is input-required; resume with ResumeInput", runID)
|
|
}
|
|
run.Status = "running"
|
|
run.State.Stage = agentAskStep
|
|
}
|
|
if run.Status == "done" {
|
|
var resp Response
|
|
if err := json.Unmarshal(run.State.Data, &resp); err != nil {
|
|
return nil, fmt.Errorf("agent run %s response decode: %w", runID, err)
|
|
}
|
|
return &resp, nil
|
|
}
|
|
if terminalAgentRunStatus(run.Status) {
|
|
return nil, fmt.Errorf("agent run %s is terminal with status %q", runID, run.Status)
|
|
}
|
|
message := string(run.State.Data)
|
|
parentID := run.ParentID
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.model == nil {
|
|
a.setup()
|
|
}
|
|
return a.askLocked(ctx, run.ID, message, parentID, &run, false)
|
|
}
|
|
|
|
// ResumeInput resumes a checkpointed agent run that paused via the built-in
|
|
// request_input tool. The supplied input is appended to the original request so
|
|
// the same run can continue with durable checkpoint and completed tool history.
|
|
func ResumeInput(ctx context.Context, ag Agent, runID, input string) (*Response, error) {
|
|
a, ok := ag.(*agentImpl)
|
|
if !ok {
|
|
return nil, fmt.Errorf("agent resume input: unsupported agent implementation %T", ag)
|
|
}
|
|
return a.resumeInput(ctx, runID, input)
|
|
}
|
|
|
|
func (a *agentImpl) resumeInput(ctx context.Context, runID, input string) (*Response, error) {
|
|
if a.opts.Checkpoint == nil {
|
|
return nil, fmt.Errorf("agent %s has no checkpoint configured", a.opts.Name)
|
|
}
|
|
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, fmt.Errorf("agent run %s not found", runID)
|
|
}
|
|
if run.Status != "paused" || run.State.Stage != agentInputStep {
|
|
return nil, fmt.Errorf("agent run %s is not waiting for human input", runID)
|
|
}
|
|
var p inputPause
|
|
if err := run.State.Scan(&p); err != nil {
|
|
return nil, fmt.Errorf("agent run %s input state decode: %w", runID, err)
|
|
}
|
|
message := p.OriginalMessage
|
|
if message == "" {
|
|
message = string(run.State.Data)
|
|
}
|
|
message += "\n\nHuman input: " + input
|
|
run.Status = "running"
|
|
run.State.Stage = agentAskStep
|
|
run.State.Data = []byte(message)
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.model == nil {
|
|
a.setup()
|
|
}
|
|
return a.askLocked(ctx, run.ID, message, run.ParentID, &run, true)
|
|
}
|
|
|
|
func (a *agentImpl) pending(ctx context.Context) ([]flow.Run, error) {
|
|
if a.opts.Checkpoint == nil {
|
|
return nil, nil
|
|
}
|
|
runs, err := a.opts.Checkpoint.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := runs[:0]
|
|
for _, run := range runs {
|
|
if run.Flow == a.opts.Name && !terminalAgentRunStatus(run.Status) {
|
|
out = append(out, run)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func terminalAgentRunStatus(status string) bool {
|
|
switch status {
|
|
case "done", "canceled", "timeout", "rate_limited", "expired":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func agentRunFailureStatus(err error) string {
|
|
switch ai.ClassifyError(err) {
|
|
case ai.ErrorKindCanceled:
|
|
return "canceled"
|
|
case ai.ErrorKindTimeout:
|
|
return "timeout"
|
|
case ai.ErrorKindRateLimited:
|
|
return "rate_limited"
|
|
default:
|
|
return "failed"
|
|
}
|
|
}
|
|
|
|
type operationalError struct {
|
|
err error
|
|
hint string
|
|
}
|
|
|
|
func (e *operationalError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
return e.err.Error() + "; " + e.hint
|
|
}
|
|
|
|
func (e *operationalError) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.err
|
|
}
|
|
|
|
func agentRunFailureAttempts(err error) int {
|
|
var retryErr *ai.RetryError
|
|
if err != nil && errors.As(err, &retryErr) && retryErr.Attempts > 0 {
|
|
return retryErr.Attempts
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func agentOperationalError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
switch ai.ClassifyError(err) {
|
|
case ai.ErrorKindCanceled:
|
|
return &operationalError{err: err, hint: "agent run canceled; inspect run history with `micro inspect agent <name> --status canceled` or see docs/guides/debugging-agents.md"}
|
|
case ai.ErrorKindTimeout:
|
|
return &operationalError{err: err, hint: "agent provider call timed out; inspect run history with `micro inspect agent <name> --status timeout`, then adjust AgentModelCallTimeout/AgentModelRetry or see docs/guides/debugging-agents.md"}
|
|
case ai.ErrorKindRateLimited:
|
|
return &operationalError{err: err, hint: "agent provider was rate limited; inspect run history with `micro inspect agent <name> --status rate_limited`, check provider keys with `micro agent preflight`, or see docs/guides/debugging-agents.md"}
|
|
case ai.ErrorKindUnavailable:
|
|
return &operationalError{err: err, hint: "agent provider appears temporarily unavailable; retry with bounded AgentModelRetry and verify provider setup with `micro agent preflight` or docs/guides/debugging-agents.md"}
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
|
|
func (a *agentImpl) checkpointToolWrap(next ai.ToolHandler) ai.ToolHandler {
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
run := a.currentRun
|
|
if a.opts.Checkpoint == nil || run == nil {
|
|
return next(ctx, call)
|
|
}
|
|
name := toolCheckpointName(call)
|
|
if rec, ok := findStep(run.Steps, name); ok && rec.Status == "done" {
|
|
return ai.ToolResult{ID: call.ID, Value: rec.Result, Content: rec.Result}
|
|
}
|
|
|
|
idx := upsertStep(&run.Steps, flow.StepRecord{Name: name, Status: "in_progress"})
|
|
_ = a.saveRun(ctx, *run)
|
|
res := next(ctx, call)
|
|
if idx < 0 || idx >= len(run.Steps) || run.Steps[idx].Name != name {
|
|
idx = upsertStep(&run.Steps, flow.StepRecord{Name: name, Status: "in_progress"})
|
|
}
|
|
run.Steps[idx].Attempts++
|
|
if res.Refused != "" {
|
|
run.Steps[idx].Status = "failed"
|
|
run.Steps[idx].Error = res.Content
|
|
_ = a.saveRun(ctx, *run)
|
|
return res
|
|
}
|
|
run.Steps[idx].Status = "done"
|
|
run.Steps[idx].Result = res.Content
|
|
run.Steps[idx].Error = ""
|
|
_ = a.saveRun(ctx, *run)
|
|
return res
|
|
}
|
|
}
|
|
|
|
func toolCheckpointName(call ai.ToolCall) string {
|
|
b, _ := json.Marshal(call.Input)
|
|
return "tool:" + call.Name + ":" + string(b)
|
|
}
|
|
|
|
func findStep(steps []flow.StepRecord, name string) (flow.StepRecord, bool) {
|
|
for _, step := range steps {
|
|
if step.Name == name {
|
|
return step, true
|
|
}
|
|
}
|
|
return flow.StepRecord{}, false
|
|
}
|
|
|
|
func upsertStep(steps *[]flow.StepRecord, rec flow.StepRecord) int {
|
|
for i := range *steps {
|
|
if (*steps)[i].Name == rec.Name {
|
|
(*steps)[i].Status = rec.Status
|
|
(*steps)[i].Error = rec.Error
|
|
return i
|
|
}
|
|
}
|
|
if len(*steps) == 0 || (*steps)[0].Name != agentAskStep {
|
|
*steps = append([]flow.StepRecord{{Name: agentAskStep, Status: "in_progress"}}, (*steps)...)
|
|
}
|
|
*steps = append(*steps, rec)
|
|
return len(*steps) - 1
|
|
}
|
|
|
|
func checkpointToolCalls(steps []flow.StepRecord) []ai.ToolCall {
|
|
calls := make([]ai.ToolCall, 0, len(steps))
|
|
for _, step := range steps {
|
|
call, ok := checkpointToolCall(step)
|
|
if !ok {
|
|
continue
|
|
}
|
|
calls = append(calls, call)
|
|
}
|
|
return calls
|
|
}
|
|
|
|
func checkpointToolCall(step flow.StepRecord) (ai.ToolCall, bool) {
|
|
if step.Status != "done" || !strings.HasPrefix(step.Name, "tool:") {
|
|
return ai.ToolCall{}, false
|
|
}
|
|
parts := strings.SplitN(strings.TrimPrefix(step.Name, "tool:"), ":", 2)
|
|
if len(parts) != 2 || parts[0] == "" {
|
|
return ai.ToolCall{}, false
|
|
}
|
|
input := map[string]any{}
|
|
if parts[1] != "null" && parts[1] != "" {
|
|
if err := json.Unmarshal([]byte(parts[1]), &input); err != nil {
|
|
return ai.ToolCall{}, false
|
|
}
|
|
}
|
|
return ai.ToolCall{Name: parts[0], Input: input, Result: step.Result}, true
|
|
}
|
|
|
|
func mergeCheckpointToolCalls(checkpointed, current []ai.ToolCall) []ai.ToolCall {
|
|
if len(checkpointed) == 0 {
|
|
return current
|
|
}
|
|
seen := make(map[string]struct{}, len(current))
|
|
for _, call := range current {
|
|
seen[toolCallKey(call.Name, call.Input)] = struct{}{}
|
|
}
|
|
merged := make([]ai.ToolCall, 0, len(checkpointed)+len(current))
|
|
for _, call := range checkpointed {
|
|
if _, ok := seen[toolCallKey(call.Name, call.Input)]; ok {
|
|
continue
|
|
}
|
|
merged = append(merged, call)
|
|
}
|
|
merged = append(merged, current...)
|
|
return merged
|
|
}
|
|
|
|
func toolCallKey(name string, input map[string]any) string {
|
|
b, _ := json.Marshal(input)
|
|
return name + ":" + string(b)
|
|
}
|