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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ The `codex` provider uses your ChatGPT subscription instead of an API key. Sign
| Flag | Default | |
|---|---|---|
| `-h, --help` | | Print usage to stdout and exit |
| `-t, --task` | — | Task prompt (required); `-` reads it from stdin |
| `-t, --task` | — | Task prompt (required unless `--resume`); `-` reads it from stdin |
| `--resume` | — | Continue a validated unfinished trajectory; requires explicit matching `--cwd` and a new `-o` path |
| `-m, --model` | `$AGENT_MODEL`, else config `model`, else `openai/gpt-4o` | `<provider>/<model>`; no `/` means openai. Providers: openai, openrouter, anthropic, ollama, codex |
| `-c, --config` | user config, else built-in | YAML config file; see [Config](#config) |
| `--cwd` | `.` | Root directory; tools can't reach outside it |
Expand All @@ -97,6 +98,31 @@ The limit flags must be >= 0, and `--cost-limit` must also be finite. A typo suc

Environment: `OPENAI_API_KEY`, `OPENAI_BASE_URL` (any OpenAI-compatible endpoint), `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `OPENROUTER_API_KEY`, `OLLAMA_HOST`, `CODEX_HOME`.

### Resume an unfinished task

```sh
# Save complete tool turns; this run stops at 20 cumulative model steps.
tinyagent -t "fix the failing test" --cwd ~/src/project --step-limit 20 --no-compact -o run.json

# Inspect the workspace (for example, git diff) and rerun relevant checks.
# Continue the same task with a ceiling of 40 total steps.
tinyagent --resume run.json --cwd ~/src/project --step-limit 40 --no-compact -o continued.json
```

`--resume <trajectory.json>` is noninteractive and uses the saved task and model. Do not pass `--task`; an explicitly different `--model` is rejected. The saved model wins over `$AGENT_MODEL` and the config's default model. Credentials and provider endpoints come from the current normal setup, never the checkpoint. Paths to checkpoints are relative to the invoking shell's directory, independently of `--cwd`.

An explicit `--cwd` must resolve to the recorded canonical root (directory symlinks are resolved). The effective system prompt must match the saved prompt, including `{{cwd}}`/`{{os}}` substitutions and the recorded AGENTS.md loading mode. Current config is used to reconstruct it without appending AGENTS.md twice. Changed instructions, a conflicting `--no-agents-md`, or changed tool definitions/compatibility require a fresh task. In particular, a task that edits its effective AGENTS.md cannot resume under changed instructions.

Only new-format, unfinished checkpoints at a complete model-query boundary are eligible: an in-progress save or a stop caused by a step/cost/time limit, interruption, or error. Successful `Submitted`/`Completed` runs cannot be extended. Legacy logs, unsupported versions/state, malformed or partial tool turns, and **compacted checkpoints** are refused before provider setup. This release does not restore the separate compacted model view; use `--no-compact` when the ability to resume later is needed. Checkpoint loading is limited to regular files of at most **64 MiB**. Larger trajectories remain audit logs.

A distinct `-o`/`--output` is required, and any existing destination is refused, including symlink and hard-link aliases of the source. The input stays unchanged. The validated starting state is saved to the new destination before the first request; completed tool turns and termination then replace it using a private temporary file. Initial publication requires filesystem hard-link support to publish atomically without replacing another file. A save failure in resume mode stops further work, prints an error, and exits nonzero, preserving the last complete checkpoint. Ordinary fresh-run save failures retain their existing warning behavior. Rename/link publication does not promise power-loss durability.

Steps, cost, token counts, and cache totals remain cumulative. Step/cost limits are cumulative ceilings: if already exhausted, no model request is made; increase the ceiling explicitly (or set it to 0 for unlimited). Runtime limits, tool timeouts, output settings, and compaction settings use normal config/flag precedence. Wall time and the stderr elapsed duration restart for each invocation; stopped time is not charged. Normal streaming, summaries, and success/failure exit codes still apply. The next action is a new model query, which incurs ordinary context/token costs; loading a checkpoint never dispatches historical tool calls.

New JSON keeps the existing top-level trajectory fields and adds versioned metadata: `schema_version` (currently 1), `root`, `system_prompt`, `skip_agents_md`, `tools`, and `tool_signature`. `resume` records the input path, previous status/submission, cumulative step, and continuation-message index; nested `previous` entries retain earlier resume boundaries. These audit paths are not read as configuration. Metadata excludes credentials and environment dumps. Checkpoints still contain prompts, code, and tool outputs: treat them as private, trusted local execution inputs. New checkpoint and temporary files use owner-only permissions (0600) where supported.

A checkpoint restores **conversation state**, not files, Git state, shell variables, live processes, or external services. The model receives a continuation notice and stderr warns to inspect and revalidate the current workspace. Commands may have produced unrecorded side effects after the last save, especially during a hard crash in a tool batch. Historical process IDs cannot reattach to children from another invocation. There is no rollback or exactly-once execution guarantee; inspect current state before resuming.

### Project instructions (AGENTS.md)

Unless `--no-agents-md` is set, tinyagent looks for `AGENTS.md` starting at `--cwd` and walking up through its parents, stopping at the repository root (the nearest directory containing `.git`). The nearest file wins, and only that one is added to the system prompt. Without a `.git` above `--cwd`, only `<cwd>/AGENTS.md` is checked. The lookup never goes above the repository root, and `-v` prints which file was loaded. This only changes the prompt: tools are still confined to `--cwd`.
Expand Down
193 changes: 182 additions & 11 deletions cmd/tinyagent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ var (

// options holds the parsed command line.
type options struct {
task, modelName, configPath, cwd, output string
stepLimit, timeLimit int
costLimit float64
verbose, noStream, noAgentsMD, noCompact bool
showHelp, showVersion, doUpdate bool
task, modelName, configPath, cwd, output, resume string
stepLimit, timeLimit int
costLimit float64
verbose, noStream, noAgentsMD, noCompact bool
showHelp, showVersion, doUpdate bool
}

// newFlagSet defines the command-line flags, storing their values in o. Each
Expand All @@ -50,7 +50,7 @@ func newFlagSet(o *options) *flag.FlagSet {
fs.Usage = func() { printUsage(fs.Output(), fs) }
fs.BoolVar(&o.showHelp, "help", false, "show this help and exit")
fs.BoolVar(&o.showHelp, "h", false, "shorthand for --help")
fs.StringVar(&o.task, "task", "", `task/prompt (required); "-" reads it from stdin`)
fs.StringVar(&o.task, "task", "", `task/prompt (required unless --resume); "-" reads it from stdin`)
fs.StringVar(&o.task, "t", "", "shorthand for --task")
fs.StringVar(&o.modelName, "model", defaultModel(), "provider/model; if unset, $AGENT_MODEL, then the config's model")
fs.StringVar(&o.modelName, "m", defaultModel(), "shorthand for --model")
Expand All @@ -62,6 +62,7 @@ func newFlagSet(o *options) *flag.FlagSet {
fs.IntVar(&o.timeLimit, "time-limit", 0, "max wall-clock seconds, 0 = unlimited")
fs.StringVar(&o.output, "output", "", "write trajectory JSON")
fs.StringVar(&o.output, "o", "", "shorthand for --output")
fs.StringVar(&o.resume, "resume", "", "continue an unfinished run from a trajectory JSON checkpoint")
fs.BoolVar(&o.verbose, "verbose", false, "print tool outputs")
fs.BoolVar(&o.verbose, "v", false, "shorthand for --verbose")
fs.BoolVar(&o.noStream, "no-stream", false, "wait for full responses instead of streaming")
Expand Down Expand Up @@ -114,6 +115,9 @@ func run() int {
fmt.Println(msg)
return 0
}
if o.resume != "" {
return runResume(&o, fs)
}
if o.task == "" {
fmt.Fprintln(os.Stderr, "error: --task is required")
fs.Usage()
Expand All @@ -132,7 +136,7 @@ func run() int {
o.modelName = resolveModel(fs, o.modelName, cfg.Model)
model.PriceOverride = [2]float64{cfg.InputPricePerMTok, cfg.OutputPricePerMTok}

root, err := filepath.Abs(o.cwd)
root, err := canonicalRoot(o.cwd)
if err != nil {
return fail(err)
}
Expand Down Expand Up @@ -174,10 +178,11 @@ func run() int {
NoCompact: o.noCompact,
}
if o.output != "" {
a.OnStep = func(t *trajectory.Trajectory) {
a.OnStep = func(t *trajectory.Trajectory) error {
if err := t.Save(o.output); err != nil {
fmt.Fprintln(os.Stderr, "warning: saving trajectory:", err)
}
return nil
}
}

Expand All @@ -190,14 +195,179 @@ func run() int {
return 1
}

// runResume continues an unfinished run from a validated checkpoint. It reuses
// the saved task and model, requires a matching working directory and a
// distinct output destination, and refuses to continue if the effective
// instructions or tool set have changed.
func runResume(o *options, fs *flag.FlagSet) int {
if flagWasSet(fs, "task", "t") {
fmt.Fprintln(os.Stderr, "error: --resume and --task are mutually exclusive")
return 2
}

if !flagWasSet(fs, "cwd") {
return fail(errors.New("resume: an explicit --cwd matching the checkpoint root is required"))
}

src, err := trajectory.Load(o.resume)
if err != nil {
return fail(fmt.Errorf("resume: %w", err))
}

// The saved model wins unless an explicit, different model is given.
if flagWasSet(fs, "model", "m") && o.modelName != src.Model {
return fail(fmt.Errorf("resume: --model %q conflicts with the checkpoint's model %q", o.modelName, src.Model))
}
modelName := src.Model

cfg, err := config.Load(config.Find(o.configPath))
if err != nil {
return fail(err)
}
applyFlagOverrides(fs, &cfg, o.stepLimit, o.costLimit, o.timeLimit)
model.PriceOverride = [2]float64{cfg.InputPricePerMTok, cfg.OutputPricePerMTok}

root, err := canonicalRoot(o.cwd)
if err != nil {
return fail(err)
}
if fi, err := os.Stat(root); err != nil || !fi.IsDir() {
return fail(fmt.Errorf("--cwd %s is not a directory", o.cwd))
}
if root != src.Root {
return fail(fmt.Errorf("resume: --cwd %s does not match the checkpoint's root %s", root, src.Root))
}

if o.output == "" {
return fail(errors.New("resume: --output is required to write the continued trajectory"))
}
if sameFile(o.resume, o.output) {
return fail(errors.New("resume: --output must be distinct from the source checkpoint"))
}
if _, err := os.Lstat(o.output); err == nil {
return fail(errors.New("resume: --output already exists; choose a new destination"))
} else if !errors.Is(err, os.ErrNotExist) {
return fail(fmt.Errorf("resume: checking --output: %w", err))
}
if flagWasSet(fs, "no-agents-md") && o.noAgentsMD != src.SkipAgentsMD {
return fail(errors.New("resume: --no-agents-md conflicts with the recorded loading mode; start a fresh task"))
}
if err := agent.ValidateResume(src, cfg, root); err != nil {
return fail(fmt.Errorf("resume: %w", err))
}

var onText func(string)
if !o.noStream {
onText = func(s string) { os.Stdout.WriteString(s) }
}
m, err := model.New(modelName, onText)
if err != nil {
code := fail(err)
printHint(os.Stderr, modelName, err.Error())
return code
}
warnUnpriced(os.Stderr, modelName, cfg.CostLimit)

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

// The agent records this boundary and clears the active termination before
// persisting the new starting state.
prevStatus := src.ExitStatus
if prevStatus == "" {
prevStatus = "in progress"
}

a := &agent.Agent{
Model: m,
ModelName: modelName,
Tools: &tools.Executor{
Root: root,
BashTimeout: time.Duration(cfg.BashTimeoutSec) * time.Second,
MaxOutputChars: cfg.MaxOutputChars,
MaxProcesses: cfg.MaxBackgroundProcesses,
Stream: os.Stdout,
},
Config: cfg,
Out: os.Stdout,
Err: os.Stderr,
Verbose: o.verbose,
Streamed: !o.noStream,
SkipAgentsMD: src.SkipAgentsMD,
NoCompact: o.noCompact,
Resume: src,
ResumeSource: o.resume,
}
initial := true
a.OnStep = func(t *trajectory.Trajectory) error {
if initial {
if err := t.SaveNew(o.output); err != nil {
return err
}
initial = false
return nil
}
return t.Save(o.output)
}

fmt.Fprintf(os.Stderr, "resuming %s from %s (previous status %s after %d steps)\n", modelName, o.resume, prevStatus, src.Steps)
fmt.Fprintln(os.Stderr, "note: inspect and revalidate the workspace; it may have changed and commands may have produced unrecorded side effects after the last save. Live processes are not restored")

start := time.Now()
t := a.Run(ctx, src.Task)
reportSummary(os.Stderr, modelName, t, time.Since(start))
if t.ExitStatus == agent.StatusSubmitted || t.ExitStatus == agent.StatusCompleted {
return 0
}
return 1
}

// sameFile reports whether two paths refer to the same file, following
// symlinks and detecting hard links.
func sameFile(a, b string) bool {
ai, errA := os.Stat(a)
bi, errB := os.Stat(b)
if errA == nil && errB == nil && os.SameFile(ai, bi) {
return true
}
ra, _ := filepath.EvalSymlinks(a)
rb, _ := filepath.EvalSymlinks(b)
if ra != "" && rb != "" && ra == rb {
return true
}
aa, _ := filepath.Abs(a)
bb, _ := filepath.Abs(b)
return filepath.Clean(aa) == filepath.Clean(bb)
}

func flagWasSet(fs *flag.FlagSet, names ...string) bool {
found := false
fs.Visit(func(f *flag.Flag) {
for _, name := range names {
if f.Name == name {
found = true
}
}
})
return found
}

func canonicalRoot(path string) (string, error) {
root, err := filepath.Abs(path)
if err != nil {
return "", err
}
return filepath.EvalSymlinks(root)
}

// usageGroups lays out --help: each flag's shorthand, long name and value
// placeholder, by group. Help text and defaults come from the FlagSet.
var usageGroups = []struct {
name string
flags [][3]string
}{
{"", [][3]string{{"h", "help", ""}}},
{"Task", [][3]string{{"t", "task", "string"}, {"m", "model", "string"}, {"c", "config", "path"}, {"", "cwd", "dir"}}},
{"Task", [][3]string{{"t", "task", "string"}, {"m", "model", "string"}, {"c", "config", "path"}, {"", "cwd", "dir"}, {"", "resume", "path"}}},
{"Limits", [][3]string{{"", "step-limit", "n"}, {"", "cost-limit", "usd"}, {"", "time-limit", "sec"}, {"", "no-compact", ""}}},
{"Output", [][3]string{{"o", "output", "path"}, {"v", "verbose", ""}, {"", "no-stream", ""}, {"", "no-agents-md", ""}}},
{"Other", [][3]string{{"V", "version", ""}, {"", "update", ""}}},
Expand All @@ -220,7 +390,7 @@ func printUsage(w io.Writer, fs *flag.FlagSet) {
width = max(width, len(left(f)))
}
}
fmt.Fprint(w, "Usage: tinyagent -t \"<prompt>\" [flags] (or --task \"<prompt>\")\n echo \"<prompt>\" | tinyagent -t -\n\n")
fmt.Fprint(w, "Usage: tinyagent -t \"<prompt>\" [flags] (or --task \"<prompt>\")\n echo \"<prompt>\" | tinyagent -t -\n tinyagent --resume <trajectory.json> --cwd <dir> -o <new.json> [flags]\n\n")
for _, g := range usageGroups {
if g.name != "" {
fmt.Fprintf(w, "%s:\n", g.name)
Expand All @@ -236,7 +406,8 @@ func printUsage(w io.Writer, fs *flag.FlagSet) {
}
fmt.Fprint(w, "\nExamples:\n"+
" tinyagent -t \"fix the failing test\" -m anthropic/claude-sonnet-4\n"+
" cat task.md | tinyagent -t - --step-limit 30 -o run.json\n")
" cat task.md | tinyagent -t - --step-limit 30 -o run.json\n"+
" tinyagent --resume run.json --cwd . --step-limit 60 -o continued.json\n")
}

// reportSummary prints one line on how the run ended: status, steps, elapsed
Expand Down
5 changes: 4 additions & 1 deletion cmd/tinyagent/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,15 @@ func TestSummaryLine(t *testing.T) {
// first and -V for --version.
const wantUsage = `Usage: tinyagent -t "<prompt>" [flags] (or --task "<prompt>")
echo "<prompt>" | tinyagent -t -
tinyagent --resume <trajectory.json> --cwd <dir> -o <new.json> [flags]

-h, --help show this help and exit
Task:
-t, --task string task/prompt (required); "-" reads it from stdin
-t, --task string task/prompt (required unless --resume); "-" reads it from stdin
-m, --model string provider/model; if unset, $AGENT_MODEL, then the config's model (default "openai/gpt-4o")
-c, --config path YAML config (default: user config if present, else built-in)
--cwd dir working directory (default ".")
--resume path continue an unfinished run from a trajectory JSON checkpoint
Limits:
--step-limit n max model calls, 0 = unlimited
--cost-limit usd max USD, 0 = unlimited
Expand All @@ -160,6 +162,7 @@ Other:
Examples:
tinyagent -t "fix the failing test" -m anthropic/claude-sonnet-4
cat task.md | tinyagent -t - --step-limit 30 -o run.json
tinyagent --resume run.json --cwd . --step-limit 60 -o continued.json
`

// runWith runs the CLI with args, returning the exit code and what went to
Expand Down
Loading
Loading