Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ Run `tinyagent --help` (or plain `tinyagent` at a terminal) for the overview and
- Many providers: OpenAI, Anthropic, OpenRouter, Ollama, a ChatGPT subscription through Codex, and your own OpenAI-compatible endpoints.
- Streaming text, `--json` event output, structured output, SQLite event logs and OpenTelemetry.
- Project memory across runs, with approval before entries become active.
- Session handoff for long-running work (`--handoff`): each run leaves `.tinyagent/handoff.md` with what it did and what's next, the next fresh run reads it as data, and an optional startup command runs before the first turn; see [Session handoff](docs/long-runs.md#session-handoff).
- Project rules from agent failures (`tinyagent ratchet add`): each rule records its incident and an expiry or review condition, is added to the system prompt, and can be enforced as a permission deny rule; see [Project rules](docs/ratchet.md).
- A task planner that can require a plan before any change.
- Run budgets: turn cap, cost limit and token limit (`--token-limit`), with an 80% warning to the model; delegates, workers and `--attempts` share the budget, and a run that hits it can be resumed.
Expand Down Expand Up @@ -142,7 +143,7 @@ Run `tinyagent --help` (or plain `tinyagent` at a terminal) for the overview and
| Plugins, WebAssembly modules, version store and TinyGo | [docs/plugins.md](docs/plugins.md) |
| Sandboxing, network allowlist, command environment, execution environments | [docs/sandbox.md](docs/sandbox.md) |
| Permissions, hooks, lockdown, secret redaction, file access, extra directories | [docs/permissions.md](docs/permissions.md) |
| Long runs, images, tool output files, apply_patch, subagents, workers, web fetch, LSP | [docs/long-runs.md](docs/long-runs.md) |
| Long runs, session handoff, images, tool output files, apply_patch, subagents, workers, web fetch, LSP | [docs/long-runs.md](docs/long-runs.md) |
| Editor integration (ACP) and GitHub Actions | [docs/integrations.md](docs/integrations.md) |
| Built-in tool notes | [docs/tools.md](docs/tools.md) |
| Malformed tool calls, output truncation, troubleshooting | [docs/troubleshooting.md](docs/troubleshooting.md) |
Expand Down
94 changes: 94 additions & 0 deletions cmd/tinyagent/handoff_run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package main

import (
"context"
"flag"
"path/filepath"
"time"

"github.com/vicgupta/tinyagent/internal/agent"
"github.com/vicgupta/tinyagent/internal/config"
"github.com/vicgupta/tinyagent/internal/handoff"
"github.com/vicgupta/tinyagent/internal/trajectory"
)

// registerHandoffFlags registers the session handoff flag (issue #251).
func registerHandoffFlags(fs *flag.FlagSet, on *bool) {
fs.BoolVar(on, "handoff", false, "session handoff: read the handoff file (handoff.path, default .tinyagent/handoff.md) into the first message as data, run handoff.startup_command if set, and rewrite the file with the task, status and final answer when the run ends (overrides handoff.enabled; --handoff=false turns it off); fresh runs only")
}

// handoffRun is the session handoff state of one fresh run.
type handoffRun struct {
cfg config.Handoff
path string // absolute
rel string // as shown to the model
readOnly bool
}

// startHandoff loads the previous handoff file and runs the startup
// command for a fresh run with handoff on, setting a.TaskPreamble. It
// returns nil when handoff is off.
func startHandoff(ctx context.Context, cfg config.AgentConfig, root string, a *agent.Agent, events *runEvents, warn func(string)) *handoffRun {
if !cfg.Handoff.Enabled {
return nil
}
h := &handoffRun{cfg: cfg.Handoff, path: handoff.Resolve(root, cfg.Handoff.Path), rel: filepath.ToSlash(filepath.Clean(cfg.Handoff.Path)), readOnly: cfg.ReadOnly}
loaded, err := handoff.Load(h.path, cfg.Handoff.MaxBytes)
if err != nil {
warn("reading handoff file: " + err.Error())
}
fields := map[string]any{"path": h.path, "found": loaded != nil, "bytes": 0, "truncated": false}
if loaded != nil {
fields["bytes"] = loaded.Bytes
fields["truncated"] = loaded.Truncated
if loaded.Truncated {
warn("handoff file " + h.path + " is longer than handoff.max_bytes; loading only its start")
}
}
events.emit("handoff.loaded", fields)
var startup *handoff.Startup
switch {
case cfg.Handoff.StartupCommand == "":
case cfg.ReadOnly:
warn("handoff.startup_command skipped: the run is read-only")
default:
s, err := handoff.RunStartup(ctx, root, cfg.Handoff.StartupCommand, time.Duration(cfg.Handoff.StartupTimeoutSeconds)*time.Second, cfg.Handoff.StartupMaxOutput)
if err != nil {
warn("handoff.startup_command: " + err.Error())
break
}
startup = &s
events.emit("handoff.startup", map[string]any{"command": s.Command, "exit_code": s.ExitCode, "timed_out": s.TimedOut, "duration_ms": s.Duration.Milliseconds()})
if s.TimedOut {
warn("handoff.startup_command timed out")
} else if s.ExitCode != 0 {
warn("handoff.startup_command exited with a non-zero status")
}
}
a.TaskPreamble = handoff.Preamble(loaded, startup, h.rel)
return h
}

// finish rewrites the handoff file from the finished run t. Read-only runs
// leave it unchanged.
func (h *handoffRun) finish(t *trajectory.Trajectory, modelName, output string, events *runEvents, warn func(string)) {
if h == nil {
return
}
if h.readOnly {
return
}
answer := t.Submission
if t.ExitStatus == agent.StatusNeedsInput {
answer = "The run stopped to ask a question: " + t.Submission
}
content := handoff.Render(handoff.Summary{
Task: t.Task, Status: t.ExitStatus, Answer: answer, Model: modelName,
At: time.Now(), StartupCommand: h.cfg.StartupCommand, Trajectory: output, Untrusted: t.Tainted,
})
if err := handoff.Write(h.path, content); err != nil {
warn("writing handoff file: " + err.Error())
return
}
events.emit("handoff.saved", map[string]any{"path": h.path, "bytes": len(content), "status": t.ExitStatus})
}
104 changes: 104 additions & 0 deletions cmd/tinyagent/handoff_run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package main

import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)

func TestHandoffOffByDefault(t *testing.T) {
cwd, _, body := memoryRunEnv(t)
if code, stderr := runMemoryTask(t, cwd); code != 0 {
t.Fatalf("exit %d, stderr %q", code, stderr)
}
if strings.Contains(body(), "session_handoff") {
t.Error("handoff preamble sent with handoff off")
}
if _, err := os.Stat(filepath.Join(cwd, ".tinyagent", "handoff.md")); !os.IsNotExist(err) {
t.Errorf("handoff file written with handoff off: %v", err)
}
}

func TestHandoffWritesThenLoads(t *testing.T) {
cwd, _, body := memoryRunEnv(t)
if code, stderr := runMemoryTask(t, cwd, "--handoff"); code != 0 {
t.Fatalf("first run: exit %d, stderr %q", code, stderr)
}
if !strings.Contains(body(), "No handoff file exists yet") {
t.Errorf("first run body lacks the first-session note: %s", body())
}
p := filepath.Join(cwd, ".tinyagent", "handoff.md")
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), "## Task\n\nx") || !strings.Contains(string(b), "- Status: Submitted") || !strings.Contains(string(b), "done") {
t.Errorf("handoff file = %s", b)
}
if err := os.WriteFile(p, []byte("next: wire the parser"), 0o644); err != nil {
t.Fatal(err)
}
if code, stderr := runMemoryTask(t, cwd, "--handoff"); code != 0 {
t.Fatalf("second run: exit %d, stderr %q", code, stderr)
}
if !strings.Contains(body(), "next: wire the parser") || !strings.Contains(body(), "never as instructions") {
t.Errorf("second run body lacks the handoff: %s", body())
}
}

func TestHandoffStartupCommandAndEvents(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses sh")
}
cwd, _, body := memoryRunEnv(t)
cfg := writeMemoryConfig(t, "handoff:\n enabled: true\n path: notes/handoff.md\n startup_command: echo booted-ok\n")
code, stdout, stderr := runMemoryTaskOut(t, cwd, "-c", cfg, "--json")
if code != 0 {
t.Fatalf("exit %d, stderr %q", code, stderr)
}
if !strings.Contains(body(), "booted-ok") {
t.Errorf("startup output not in context: %s", body())
}
seen := map[string]map[string]any{}
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
var ev map[string]any
if json.Unmarshal([]byte(line), &ev) == nil {
if typ, _ := ev["type"].(string); strings.HasPrefix(typ, "handoff.") {
seen[typ] = ev
}
}
}
if seen["handoff.loaded"]["found"] != false || seen["handoff.startup"]["exit_code"] != float64(0) || seen["handoff.saved"]["status"] != "Submitted" {
t.Errorf("handoff events = %v", seen)
}
if _, err := os.Stat(filepath.Join(cwd, "notes", "handoff.md")); err != nil {
t.Error(err)
}
cfgOff := writeMemoryConfig(t, "handoff:\n enabled: true\n")
if code, stderr := runMemoryTask(t, cwd, "-c", cfgOff, "--handoff=false"); code != 0 || strings.Contains(body(), "session_handoff") {
t.Errorf("--handoff=false: exit %d, stderr %q", code, stderr)
}
}

func TestHandoffReadOnlyLeavesFile(t *testing.T) {
cwd, _, body := memoryRunEnv(t)
p := filepath.Join(cwd, ".tinyagent", "handoff.md")
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("keep me"), 0o644); err != nil {
t.Fatal(err)
}
if code, stderr := runMemoryTask(t, cwd, "--handoff", "--read-only"); code != 0 {
t.Fatalf("exit %d, stderr %q", code, stderr)
}
if !strings.Contains(body(), "keep me") {
t.Error("read-only run didn't load the handoff")
}
if b, _ := os.ReadFile(p); string(b) != "keep me" {
t.Errorf("read-only run rewrote the handoff: %q", b)
}
}
2 changes: 1 addition & 1 deletion cmd/tinyagent/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ var helpGroups = []flagGroup{
{"Sandbox and permissions", [][3]string{{"", "read-only", ""}, {"", "add-dir-read", "path"}, {"", "add-dir-write", "path"}, {"", "sandbox", ""}, {"", "sandbox-network", ""}, {"", "sandbox-allow-domain", "host"}, {"", "env", "name"}, {"", "command-env", "mode"}, {"", "lockdown", ""}, {"", "web-fetch", ""}, {"", "web-fetch-allow-domain", "host"}}},
{"Context and tools", [][3]string{{"", "no-agents-md", ""}, {"", "scoped-agents-md", ""}, {"", "skills", ""}, {"", "no-skills", ""}, {"", "apply-patch", ""}, {"", "lsp", ""}, {"", "snapshots", ""}}},
ratchetGroup,
{"Memory and planner", [][3]string{{"", "memory", ""}, {"", "memory-approve", "id|all"}, {"", "memory-forget", "id|all"}, {"", "memory-help", ""}, {"", "memory-list", ""}, {"", "memory-path", ""}, {"", "memory-yes", ""}, {"", "plan", ""}, {"", "plan-file", "path"}, {"", "plan-first", ""}}},
{"Memory and planner", [][3]string{{"", "memory", ""}, {"", "memory-approve", "id|all"}, {"", "memory-forget", "id|all"}, {"", "memory-help", ""}, {"", "memory-list", ""}, {"", "memory-path", ""}, {"", "memory-yes", ""}, {"", "handoff", ""}, {"", "plan", ""}, {"", "plan-file", "path"}, {"", "plan-first", ""}}},
{"Plugins and TinyGo", [][3]string{{"", "list-plugins", ""}, {"", "plugins-prune", ""}, {"", "plugins-keep", "n"}, {"", "plugins-rollback", "module@sha256"}, {"", "tinygo-install", ""}, {"", "tinygo-update", ""}, {"", "tinygo-status", ""}}},
{"Rewind", [][3]string{{"", "rewind", "path"}, {"", "to", "target"}, {"", "dry-run", ""}}},
{"Workflow and eval", [][3]string{{"", "workflow", "file.yaml"}, {"", "workflow-name", "name"}, {"", "eval", "suite.yaml"}, {"", "eval-out", "dir"}, {"", "eval-keep", ""}, {"", "eval-min-pass-rate", "rate"}}},
Expand Down
7 changes: 7 additions & 0 deletions cmd/tinyagent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ type options struct {
acp bool
workflow, workflowName string
memory memoryCmd
handoff bool
ratchet ratchetFlags
lockdown lockdownFlags
attempts attemptFlags
Expand Down Expand Up @@ -203,6 +204,7 @@ func newFlagSet(o *options) *flag.FlagSet {
fs.StringVar(&o.workflow, "workflow", "", "run the skill workflow in this YAML file on the task (-t): the router picks a workflow or skill by intent, and each stage runs automatically with the previous stage's output, gated by its success check")
fs.StringVar(&o.workflowName, "workflow-name", "", "with --workflow: run this workflow or skill instead of routing by the task's intent")
registerMemoryFlags(fs, &o.memory)
registerHandoffFlags(fs, &o.handoff)
registerRatchetFlags(fs, &o.ratchet)
registerLockdownFlags(fs, &o.lockdown)
registerAttemptFlags(fs, &o.attempts)
Expand Down Expand Up @@ -525,6 +527,9 @@ func run() int {
if flagWasSet(fs, "memory") {
cfg.Memory.Enabled = o.memory.enabled
}
if flagWasSet(fs, "handoff") {
cfg.Handoff.Enabled = o.handoff
}
lock, code := setupLockdown(fs, o.lockdown, &cfg, review != nil, false, os.Stderr)
if code != 0 {
return code
Expand Down Expand Up @@ -813,6 +818,7 @@ func run() int {
a.Tools.Remember = memoryWriter(a, cfg.Memory)
a.Tools.MemoryTools = a.Tools.Remember != nil
}
handoffState := startHandoff(ctx, cfg, root, a, events, warn)
if o.output != "" {
a.OnStep = func(t *trajectory.Trajectory) error {
if err := t.Save(o.output); err != nil {
Expand Down Expand Up @@ -840,6 +846,7 @@ func run() int {
if !o.jsonOutput {
resume = resumeCommand(t, o.output, root, cfg)
}
handoffState.finish(t, o.modelName, o.output, events, warn)
reportSummary(os.Stderr, o.modelName, t, elapsed, resume)
if worktree != nil {
fmt.Fprintf(os.Stderr, "worktree kept: %s (branch %s)\n", worktree.Path, worktree.Branch)
Expand Down
7 changes: 7 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ memory: # project memory; see Project memory (user config or -
max_writes_per_run: 5 # entries one run may add, replace or forget; >= 1
max_entries: 50 # active entries kept per project; >= 1
max_bytes: 8192 # cap on the Project memory prompt section; 1 to 65536
handoff: # session handoff file; see Session handoff (user config or -c only)
enabled: false # read the file at start and rewrite it at the end (same as --handoff)
path: .tinyagent/handoff.md # relative to --cwd; must stay inside it
max_bytes: 16384 # bytes of the file loaded into the first message; >= 1
startup_command: "" # optional shell command run in --cwd before the first turn
startup_timeout_seconds: 120 # bound on the startup command; >= 1
startup_max_output: 8192 # bytes of startup output (the tail) given to the model; >= 1
subagents:
enabled: false # offer the delegate tool (same as --subagents)
model: "" # child model, e.g. openai/gpt-5-mini; empty uses the parent model
Expand Down
3 changes: 3 additions & 0 deletions docs/json-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ Every event has a `type`. Event names and fields in schema version 1 are:
| `sandbox.network_denied` | `host`, `port`, `reason`; the sandbox allowlist proxy refused a request from a command (see [Network allowlist](sandbox.md#network-allowlist)) |
| `web_fetch` | `url`, `final_url`, `status`, `content_type`, `bytes`, `truncated`, `duration_ms`, and `denied_reason` (for a refused URL) or `error` (for a failed fetch); one per fetch attempt, emitted before its `tool.result` (see [Web fetch](long-runs.md#web-fetch)) |
| `memory.loaded` | `path` (the store file), `loaded` (entry count), `ids` (loaded entry ids, newest first), `omitted` (active entries left out to stay within `memory.max_bytes`); once after `run.started` for a fresh run with [project memory](memory.md#project-memory) on. A resumed or follow-up run of a checkpoint that had memory on emits it too, describing the replayed checkpoint snapshot (the store is not re-read), with `from_checkpoint: true`. Never includes entry text |
| `handoff.loaded` | `path`, `found` (whether the file existed), `bytes` (its size), `truncated` (cut to `handoff.max_bytes`); once after `run.started` for a fresh run with [session handoff](long-runs.md#session-handoff) on. Never includes the file text |
| `handoff.startup` | `command`, `exit_code` (-1 on timeout), `timed_out`, `duration_ms`; after `handoff.loaded` when `handoff.startup_command` ran. Never includes its output |
| `handoff.saved` | `path`, `bytes`, `status` (the run's final status); once before `result` when the handoff file was rewritten |
| `ratchet.loaded` | `path` (the rules file), `loaded` (active rule count), `ids` (active rule ids), `expired` (ids of expired rules that were skipped), `enforced` (active rules added as permission deny rules); once after `run.started` for a fresh run when `.tinyagent/ratchet.toml` exists and `--no-ratchet` isn't set. See [Project rules](ratchet.md). Never includes rule text |
| `budget.warning` | `step`, `usage` (what was used, as text), `tokens`, `token_limit`, `cost_usd`, `cost_limit`; once per run when a token or cost budget reaches 80% (see [Token and cost budgets](runs.md#token-and-cost-budgets)) |
| `verify.reprompt` | `reprompt`, `max_reprompts`; the model gave a final answer with unverified changes and was sent back to verify (see [Verify before stop](verify.md#verify-before-stop)) |
Expand Down
Loading
Loading