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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ tinyagent --update

This replaces the binary in place with the latest release, after checking its checksum. If it's installed in a directory you can't write to, run it with `sudo`.

Each step is printed to stderr as it happens, and the download shows a `#` progress bar when stderr is a terminal:

```text
update: current version: v0.10.3 at /usr/local/bin/tinyagent
update: query latest release: https://api.github.com/repos/vicgupta/tinyagent/releases/latest
update: query latest release: found v0.10.4 (current v0.10.3)
update: select asset: tinyagent-linux-amd64 for linux/amd64
update: download checksums: SHA256SUMS
update: download binary: https://github.com/vicgupta/tinyagent/releases/download/v0.10.4/tinyagent-linux-amd64
[####################....................] 50% 4.5 MiB / 9.0 MiB
```

It ends with the received size, the verified SHA-256, the install step and `update: done: updated v0.10.3 -> v0.10.4 at <path>`. If a step fails, the error names it: `error: update failed at verify checksum: ...`. See [`--update`](docs/subcommands.md#updating) for the details.

### Shell completion (Bash, Zsh and Fish)

`tinyagent --completion bash`, `tinyagent --completion zsh` and `tinyagent --completion fish` print a script that completes flag names (`--sandbox-<Tab>`, `--tool-out<Tab>`, short aliases such as `-t`). After `--env`, `--command-env`, `--reasoning-effort`, `--openai-api` and `--completion` (as `--flag <Tab>` or `--flag=<Tab>`) they also complete that flag's fixed values, so `--env <Tab>` offers `local` and `container`. Other flag values, such as models and profiles, are not completed; those words fall back to filenames.
Expand Down Expand Up @@ -120,6 +134,7 @@ Run `tinyagent --help` (or plain `tinyagent` at a terminal) for the overview and
- 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.
- Verify before stop (`--verify-on-stop`): a run that changed files isn't accepted until a test or build command passes; otherwise it is marked unverified. Optional computational checks (`--verify-check`, e.g. lint, type check, tests) run in order, cheapest first, before the run may finish; see [Computational checks](docs/verify.md#computational-checks). An optional inferential review (`--verify-review`) runs last, once every check passes, and reports the model's findings; see [Inferential review](docs/verify.md#inferential-review).
- Help everywhere: `tinyagent` or `tinyagent <subcommand>` alone at a terminal prints help; `--help --verbose` lists every flag by section.
- `tinyagent --update`: checksum-verified self-update with step-by-step progress and a `#` download bar.
- Undo for file edits, workspace rewind, resume and follow-up runs.
- Sandboxed commands with a network allowlist, permission rules, hooks, untrusted-content lockdown and file-access rules.
- WebAssembly plugin modules run through Extism with permission checks, a version store, rollback and prune. Go source modules are built with TinyGo, an external dependency that tinyagent downloads on first use (pinned version, checksum-verified, cached locally, `tinyagent tinygo update` to update); TinyGo is never embedded in tinyagent.
Expand Down
7 changes: 1 addition & 6 deletions cmd/tinyagent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,12 +351,7 @@ func run() int {
if err != nil {
return fail(err)
}
msg, err := u.Run(context.Background())
if err != nil {
return fail(err)
}
fmt.Println(msg)
return 0
return runUpdate(u, o.jsonOutput, os.Stdout, os.Stderr, isTerminal(os.Stderr))
}
if o.check {
return runCheck(&o, fs, os.Stdout)
Expand Down
39 changes: 39 additions & 0 deletions cmd/tinyagent/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package main

import (
"context"
"encoding/json"
"fmt"
"io"

"github.com/vicgupta/tinyagent/internal/update"
)

// runUpdate runs --update with step-by-step progress on stderr (a '#' bar
// while downloading when stderr is a terminal) and the final message on
// stdout. With --json, steps and the result are JSONL events on stdout and
// stderr gets nothing but errors.
func runUpdate(u *update.Updater, jsonOut bool, stdout, stderr io.Writer, tty bool) int {
if jsonOut {
u.Report = &update.JSONReporter{W: stdout}
} else {
u.Report = &update.TextReporter{W: stderr, Bar: tty}
}
msg, err := u.Run(context.Background())
if jsonOut {
ev := map[string]string{"type": "update.result", "status": "ok", "message": msg}
if err != nil {
ev["status"], ev["message"] = "error", err.Error()
}
b, _ := json.Marshal(ev)
fmt.Fprintf(stdout, "%s\n", b)
}
if err != nil {
fmt.Fprintln(stderr, "error:", err)
return 1
}
if !jsonOut {
fmt.Fprintln(stdout, msg)
}
return 0
}
75 changes: 75 additions & 0 deletions cmd/tinyagent/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package main

import (
"bufio"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/vicgupta/tinyagent/internal/update"
)

func fakeUpToDate(t *testing.T) *update.Updater {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"tag_name":"v0.1.0","assets":[]}`))
}))
t.Cleanup(srv.Close)
return &update.Updater{APIURL: srv.URL, Current: "v0.1.0", ExePath: "/x/tinyagent", Client: srv.Client(), GOOS: "linux", GOARCH: "amd64"}
}

func TestRunUpdateText(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := runUpdate(fakeUpToDate(t), false, &stdout, &stderr, false); code != 0 {
t.Fatalf("exit %d: %s", code, stderr.String())
}
if stdout.String() != "already up to date (v0.1.0)\n" {
t.Errorf("stdout %q", stdout.String())
}
if !strings.Contains(stderr.String(), "update: current version: v0.1.0 at /x/tinyagent\n") || !strings.Contains(stderr.String(), "already up to date") {
t.Errorf("stderr %q", stderr.String())
}
}

func TestRunUpdateJSONKeepsStdoutClean(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := runUpdate(fakeUpToDate(t), true, &stdout, &stderr, true); code != 0 {
t.Fatalf("exit %d", code)
}
if stderr.Len() != 0 {
t.Errorf("stderr %q", stderr.String())
}
sc := bufio.NewScanner(&stdout)
var types []string
for sc.Scan() {
var ev map[string]string
if err := json.Unmarshal(sc.Bytes(), &ev); err != nil {
t.Fatalf("not JSON: %q", sc.Text())
}
types = append(types, ev["type"])
}
if len(types) < 3 || types[0] != "update.step" || types[len(types)-1] != "update.result" {
t.Errorf("events %v", types)
}
}

func TestRunUpdateErrorNamesStep(t *testing.T) {
u := fakeUpToDate(t)
u.APIURL += "/nope"
u.Client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 503, Status: "503 Service Unavailable", Body: http.NoBody}, nil
})}
var stdout, stderr bytes.Buffer
if code := runUpdate(u, false, &stdout, &stderr, false); code != 1 {
t.Fatalf("exit %d", code)
}
if !strings.Contains(stderr.String(), "error: update failed at query latest release: ") {
t.Errorf("stderr %q", stderr.String())
}
}

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
2 changes: 1 addition & 1 deletion docs/json-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

### Machine-readable output

`--json` makes stdout a UTF-8 JSONL event stream, with one complete JSON object and newline per event. It can be combined with `--no-stream`, `--read-only`, `--resume`, and `-o` (which still writes the trajectory JSON to its file). In this mode, streamed assistant text arrives as `assistant.delta` events while the model generates (unless `--no-stream`), live bash output is represented by events, and `--verbose` has no effect because tool calls and results are already events; the human summary and warnings remain on stderr. Setup failures before a run starts may leave stdout empty.
`--json` makes stdout a UTF-8 JSONL event stream, with one complete JSON object and newline per event. It can be combined with `--no-stream`, `--read-only`, `--resume`, and `-o` (which still writes the trajectory JSON to its file). In this mode, streamed assistant text arrives as `assistant.delta` events while the model generates (unless `--no-stream`), live bash output is represented by events, and `--verbose` has no effect because tool calls and results are already events; the human summary and warnings remain on stderr. Setup failures before a run starts may leave stdout empty. With `--update`, stdout carries `update.step` and `update.result` events instead (see [Updating](subcommands.md#updating)).

Every event has a `type`. Event names and fields in schema version 1 are:

Expand Down
10 changes: 9 additions & 1 deletion docs/subcommands.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,18 @@ Six subcommands are shortcuts for existing flags. They rewrite the command line
| `--no-agents-md` | off | Don't append the nearest `AGENTS.md` (at or above `<cwd>`, up to the repo root; capped at 32KB) to the system prompt |
| `--scoped-agents-md` | off | Also deliver `AGENTS.md` files in directories below `<cwd>`, appended to the first file tool result that touches a path there; see [Project instructions](project-context.md#project-instructions-agentsmd). Can't be combined with `--no-agents-md` |
| `-V, --version` | | Print the version and exit |
| `--update` | | Replace this binary with the latest GitHub release (checksum-verified) and exit |
| `--update` | | Replace this binary with the latest GitHub release (checksum-verified) and exit, printing each step to stderr; see [Updating](#updating) |
| `--check` | | Print the resolved config file, model, provider key status, fallbacks, cwd and limits (and `ask_user` when it is on or set by flag, and the [project memory](memory.md#project-memory) approval policy, store path and active/pending entry counts when memory is on or `--memory` is passed, a `lockdown:` line when [lockdown](permissions.md#untrusted-content-lockdown) is on or `--lockdown` is passed, and an `attempts:` line when `--attempts` flags are passed or the config has an `attempts:` section: the validated best-of-N plan with each attempt's model, the check count and timeouts (never the commands), `stop_on_pass`, `keep` and the worktree paths), then exit without a model request (exit 2 on a setup error) |
| `--completion bash\|zsh\|fish` | | Print a Bash, Zsh or Fish flag-name completion script and exit (`source <(tinyagent --completion bash)`; for Zsh, after `compinit`; for Fish, `tinyagent --completion fish \| source`); see [Shell completion](../README.md#shell-completion-bash-zsh-and-fish) |

## Updating

`tinyagent --update` prints one `update: <step>: <detail>` line on stderr per step: `current version` (version and binary path), `query latest release` (the API URL, then `found vX` or `latest is vX; already up to date`), `select asset` (`tinyagent-<os>-<arch>`), `download checksums` (`SHA256SUMS`), `download binary` (the URL, then the received size), `verify checksum` (the SHA-256 that matched), `install` (the path being replaced) and `done`. The final message (`updated vA -> vB (<path>)` or `already up to date (vA)`) goes to stdout, exit 0.

- **Download bar.** When stderr is a terminal, the binary download draws a `#` bar: with a known `Content-Length`, one redrawn line ` [####....] 42% 3.8 MiB / 9.0 MiB`; without one, one `#` per 256 KiB received, then the total. When stderr isn't a terminal (piped, CI logs), only the plain step lines are printed, with no carriage returns.
- **Failures** exit 1 and name the step: `error: update failed at <step>: <reason>`, for example `update failed at verify checksum: checksum mismatch for tinyagent-linux-amd64; not installing`. The binary is replaced only after the checksum matches.
- **`--json`.** Steps become JSONL events on stdout, `{"type":"update.step","step":"...","detail":"..."}`, followed by `{"type":"update.result","status":"ok"|"error","message":"..."}`; no bar is drawn and stderr only gets the error line on failure.

## Exit status

The run ends when the model writes `COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` (`Submitted`), stops calling tools (`Completed`), or hits a limit (`StepLimitExceeded`, `CostLimitExceeded`, `TokenLimitExceeded`, `TimeLimitExceeded`), Ctrl-C (`Interrupted`), a non-retryable API error (`Error`), too many consecutive malformed tool-call steps (`RepeatedFormatError`, see below), or, with `--output-schema`, 3 consecutive invalid results (`InvalidOutput`), or stop hooks still rejecting the result after `max_stop_continuations` (`VerificationFailed`, see [Stop hooks](permissions.md#stop-hooks)), or too many consecutive responses cut off at the output-token limit (`OutputTruncated`, see [Output truncation](troubleshooting.md#output-truncation)), or, with `--ask-user`, a question for the user (`NeedsInput`, see [Asking for input](runs.md#asking-for-input)). The process exits 0 only for `Submitted` and `Completed`, 3 for `OutputTruncated`, 4 for `NeedsInput`, and 1 otherwise (2 is reserved for usage errors).
Expand Down
147 changes: 147 additions & 0 deletions internal/update/progress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package update

import (
"encoding/json"
"fmt"
"io"
"strings"
)

// Step names, in the order Run goes through them. Errors from Run name the
// step that failed ("update failed at <step>: ...").
const (
StepCurrent = "current version"
StepQuery = "query latest release"
StepSelect = "select asset"
StepChecksum = "download checksums"
StepDownload = "download binary"
StepVerify = "verify checksum"
StepInstall = "install"
StepDone = "done"
)

// Reporter receives Run's progress. Step is called once per step with a
// human-readable detail; Download is called before the binary download and
// returns a writer fed every byte as it arrives (nil for none) and a finish
// func called once the body is read or fails.
type Reporter interface {
Step(step, detail string)
Download(name string, total int64) (io.Writer, func())
}

// TextReporter prints one "update: <step>: <detail>" line per step to W.
// With Bar set (W is a terminal), the download draws a '#' progress bar;
// otherwise the download prints only plain lines, with no carriage returns.
type TextReporter struct {
W io.Writer
Bar bool
}

func (r *TextReporter) Step(step, detail string) {
fmt.Fprintf(r.W, "update: %s: %s\n", step, detail)
}

func (r *TextReporter) Download(name string, total int64) (io.Writer, func()) {
if !r.Bar {
return nil, func() {}
}
p := &ProgressBar{W: r.W, Total: total}
return p, p.Finish
}

// BarWidth is the number of cells in a known-length progress bar.
const BarWidth = 40

// ChunkSize is how many bytes one '#' stands for when the length is unknown.
const ChunkSize = 256 << 10

// ProgressBar draws download progress to W. With Total > 0 it redraws one
// line, "\r [####....] 42% 1.2 MiB / 2.9 MiB", as bytes arrive; with
// Total <= 0 it appends one '#' per ChunkSize bytes. Finish ends the line.
type ProgressBar struct {
W io.Writer
Total int64
n int64
drawn int // known: last percent drawn (-1 before any); unknown: '#'s printed
began bool
}

func (p *ProgressBar) Write(b []byte) (int, error) {
p.n += int64(len(b))
if p.Total > 0 {
pct := int(p.n * 100 / p.Total)
if pct > 100 {
pct = 100
}
if !p.began || pct != p.drawn {
p.began = true
p.drawn = pct
p.draw(pct)
}
return len(b), nil
}
if !p.began {
p.began = true
fmt.Fprint(p.W, " ")
}
for want := int(p.n / ChunkSize); p.drawn < want; p.drawn++ {
fmt.Fprint(p.W, "#")
}
return len(b), nil
}

func (p *ProgressBar) draw(pct int) {
fill := pct * BarWidth / 100
fmt.Fprintf(p.W, "\r [%s%s] %3d%% %s / %s", strings.Repeat("#", fill), strings.Repeat(".", BarWidth-fill), pct, Size(p.n), Size(p.Total))
}

// Finish ends the progress line.
func (p *ProgressBar) Finish() {
if p.Total > 0 {
if !p.began {
p.draw(0)
}
fmt.Fprintln(p.W)
return
}
if !p.began {
fmt.Fprint(p.W, " ")
}
fmt.Fprintf(p.W, " %s\n", Size(p.n))
}

// Size formats a byte count as B, KiB or MiB.
func Size(n int64) string {
switch {
case n >= 1<<20:
return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
case n >= 1<<10:
return fmt.Sprintf("%.1f KiB", float64(n)/(1<<10))
}
return fmt.Sprintf("%d B", n)
}

// JSONReporter writes each step as a JSONL event
// {"type":"update.step","step":...,"detail":...} to W, and no progress bar.
type JSONReporter struct{ W io.Writer }

func (r *JSONReporter) Step(step, detail string) {
b, _ := json.Marshal(map[string]string{"type": "update.step", "step": step, "detail": detail})
fmt.Fprintf(r.W, "%s\n", b)
}

func (r *JSONReporter) Download(string, int64) (io.Writer, func()) { return nil, func() {} }

type nopReporter struct{}

func (nopReporter) Step(string, string) {}
func (nopReporter) Download(string, int64) (io.Writer, func()) { return nil, func() {} }

// StepError is an error from one step of Run.
type StepError struct {
Step string
Err error
}

func (e *StepError) Error() string { return fmt.Sprintf("update failed at %s: %v", e.Step, e.Err) }
func (e *StepError) Unwrap() error { return e.Err }
Loading
Loading