chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# Durable agent run resume
|
||||
|
||||
This example shows the agent-side counterpart to `examples/flow-durable`: an
|
||||
agent run is checkpointed with the same `Checkpoint` interface used by flows,
|
||||
then resumed after an interruption without repeating a completed side effect.
|
||||
The sample uses an in-memory store to keep repeated local runs deterministic;
|
||||
use your service store for process-restart recovery.
|
||||
|
||||
Run it with:
|
||||
|
||||
```sh
|
||||
go run ./examples/agent-durable
|
||||
```
|
||||
|
||||
The demo model calls `inventory.reserve`, then fails to mimic a process dying
|
||||
after the tool call was checkpointed. `micro.AgentPending` finds the unfinished
|
||||
run and `micro.AgentResume` continues it from the saved checkpoint. The final
|
||||
`tool executions: 1` line is the important bit: the reservation tool was not
|
||||
called a second time during resume.
|
||||
|
||||
## When to use this instead of a durable flow
|
||||
|
||||
Use a durable flow when the path is known ahead of time: ordered service calls,
|
||||
retries, timers, compensation, and a precise resume stage such as `reserve` or
|
||||
`charge`. Use a checkpointed agent run when the path is open-ended and the model
|
||||
may choose tools dynamically, but completed tool side effects still must not be
|
||||
replayed after a crash or provider failure.
|
||||
|
||||
They compose: keep deterministic business process in `flow-durable`, then hand
|
||||
off the judgment-heavy step to a checkpointed agent when the workflow needs
|
||||
model-directed tool use. Both use the same `Checkpoint` backend, so inspection
|
||||
and recovery can share one run-history store.
|
||||
|
||||
In a service, use the same pattern at startup:
|
||||
|
||||
```go
|
||||
pending, _ := micro.AgentPending(ctx, agent)
|
||||
for _, run := range pending {
|
||||
_, _ = micro.AgentResume(ctx, agent, run.ID)
|
||||
}
|
||||
```
|
||||
|
||||
`context.Context` cancellation and deadlines are still honored by checkpoint
|
||||
loads/saves, model calls, and tool calls. Runs with terminal statuses such as
|
||||
`done`, `canceled`, and `expired` are not returned by `AgentPending`.
|
||||
@@ -0,0 +1,88 @@
|
||||
// Package main demonstrates durable agent runs: a checkpointed agent can
|
||||
// resume after a crash without re-executing completed tool calls.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
micro "go-micro.dev/v6"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
checkpoint := micro.StoreCheckpoint(store.NewMemoryStore(), "durable-agent-demo")
|
||||
model := &demoModel{failFirst: true}
|
||||
ai.Register("durable-demo", func(opts ...ai.Option) ai.Model {
|
||||
_ = model.Init(opts...)
|
||||
return model
|
||||
})
|
||||
var reservations atomic.Int32
|
||||
|
||||
ag := micro.NewAgent("durable-agent-demo",
|
||||
micro.AgentWithCheckpoint(checkpoint),
|
||||
micro.AgentProvider("durable-demo"),
|
||||
micro.AgentTool("inventory.reserve", "reserve inventory exactly once", map[string]any{
|
||||
"sku": map[string]any{"type": "string"},
|
||||
}, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
count := reservations.Add(1)
|
||||
return fmt.Sprintf("reserved %s (execution %d)", input["sku"], count), nil
|
||||
}),
|
||||
)
|
||||
|
||||
_, err := ag.Ask(ctx, "reserve sku-123 and confirm")
|
||||
fmt.Println("initial run:", err)
|
||||
|
||||
pending, err := micro.AgentPending(ctx, ag)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
panic("expected a checkpointed run to resume")
|
||||
}
|
||||
|
||||
resp, err := micro.AgentResume(ctx, ag, pending[0].ID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("resumed reply:", resp.Reply)
|
||||
fmt.Println("tool executions:", reservations.Load())
|
||||
}
|
||||
|
||||
type demoModel struct {
|
||||
failFirst bool
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
func (m *demoModel) Init(opts ...ai.Option) error {
|
||||
m.opts = ai.NewOptions(opts...)
|
||||
return nil
|
||||
}
|
||||
func (m *demoModel) Options() ai.Options { return m.opts }
|
||||
func (m *demoModel) String() string { return "durable-demo" }
|
||||
|
||||
func (m *demoModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
if m.opts.ToolHandler != nil {
|
||||
res := m.opts.ToolHandler(ctx, ai.ToolCall{
|
||||
ID: "reserve-1",
|
||||
Name: "inventory.reserve",
|
||||
Input: map[string]any{"sku": "sku-123"},
|
||||
})
|
||||
if res.Content == "" {
|
||||
return nil, errors.New("reservation tool returned no content")
|
||||
}
|
||||
}
|
||||
if m.failFirst {
|
||||
m.failFirst = false
|
||||
return nil, errors.New("simulated process interruption after checkpointed tool call")
|
||||
}
|
||||
return &ai.Response{Reply: "sku-123 is reserved; no duplicate reservation was made"}, nil
|
||||
}
|
||||
|
||||
func (m *demoModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, ai.ErrStreamingUnsupported
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDurableAgentExampleResumesWithoutReplayingTool(t *testing.T) {
|
||||
out := captureStdout(t, main)
|
||||
if !strings.Contains(out, "simulated process interruption after checkpointed tool call") {
|
||||
t.Fatalf("example output %q did not show the initial interrupted run", out)
|
||||
}
|
||||
if !strings.Contains(out, "resumed reply: sku-123 is reserved; no duplicate reservation was made") {
|
||||
t.Fatalf("example output %q did not show the resumed response", out)
|
||||
}
|
||||
if !strings.Contains(out, "tool executions: 1") {
|
||||
t.Fatalf("example output %q did not prove the tool was not replayed", out)
|
||||
}
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe stdout: %v", err)
|
||||
}
|
||||
os.Stdout = w
|
||||
|
||||
var buf bytes.Buffer
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = io.Copy(&buf, r)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
fn()
|
||||
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
<-done
|
||||
_ = r.Close()
|
||||
return buf.String()
|
||||
}
|
||||
Reference in New Issue
Block a user