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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,15 @@ tinyagent --read-only --cwd ~/src/project -t "Review error handling in pkg/api"
cat review-task.md | tinyagent --read-only -t - -o review.json
```

Run `tinyagent --help` (or plain `tinyagent` at a terminal) for the overview and `tinyagent --help --verbose` for every flag. Subcommands are shortcuts for common flags: `tinyagent sandbox`, `tinyagent memory list`, `tinyagent planner --first`, `tinyagent plugins list`, `tinyagent tinygo status` (see [CLI reference](docs/subcommands.md)); each one alone, such as `tinyagent sandbox`, prints its help.
Run `tinyagent --help` (or plain `tinyagent` at a terminal) for the overview and `tinyagent --help --verbose` for every flag. Subcommands are shortcuts for common flags: `tinyagent sandbox`, `tinyagent memory list`, `tinyagent ratchet list`, `tinyagent planner --first`, `tinyagent plugins list`, `tinyagent tinygo status` (see [CLI reference](docs/subcommands.md)); each one alone, such as `tinyagent sandbox`, prints its help.

## Features

- Small set of file, search, shell and background-process tools, plus opt-in planning, skills, images, patches, subagents, web fetch, LSP and MCP tools.
- 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.
- 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.
- 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.
Expand All @@ -135,6 +136,7 @@ Run `tinyagent --help` (or plain `tinyagent` at a terminal) for the overview and
| Verify before stop (`--verify-on-stop`) | [docs/verify.md](docs/verify.md) |
| `undo_file` and `--rewind` | [docs/undo.md](docs/undo.md) |
| Project memory | [docs/memory.md](docs/memory.md) |
| Project rules from agent failures (`ratchet`) | [docs/ratchet.md](docs/ratchet.md) |
| Planner (`update_plan`, `--plan-first`) | [docs/planner.md](docs/planner.md) |
| AGENTS.md, skills and skill workflows | [docs/project-context.md](docs/project-context.md) |
| Plugins, WebAssembly modules, version store and TinyGo | [docs/plugins.md](docs/plugins.md) |
Expand Down
1 change: 1 addition & 0 deletions cmd/tinyagent/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ var helpGroups = []flagGroup{
{"Output and logging", [][3]string{{"o", "output", "path"}, {"v", "verbose", ""}, {"", "no-stream", ""}, {"", "json", ""}, {"", "output-schema", "path"}, {"", "log-db", "[=path]"}, {"", "otel", ""}, {"", "tool-output-files", ""}, {"", "redact-secrets", ""}, {"", "transcript", "path"}}},
{"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", ""}}},
{"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", ""}}},
Expand Down
15 changes: 15 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
ratchet ratchetFlags
lockdown lockdownFlags
attempts attemptFlags
verify verifyFlags
Expand Down Expand Up @@ -202,6 +203,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)
registerRatchetFlags(fs, &o.ratchet)
registerLockdownFlags(fs, &o.lockdown)
registerAttemptFlags(fs, &o.attempts)
return fs
Expand Down Expand Up @@ -272,6 +274,9 @@ func run() int {
if memoryCommandSet(fs) {
return runMemoryCommand(&o, fs, os.Stdout, os.Stderr)
}
if ratchetCommandSet(fs) {
return runRatchetCommand(&o, fs, os.Stdout, os.Stderr)
}
if o.acp {
if f := acpConflict(fs); f != "" {
fmt.Fprintf(fs.Output(), "error: --acp can't be combined with %s: the editor supplies each session's prompts and cwd\n", f)
Expand Down Expand Up @@ -612,6 +617,15 @@ func run() int {
return configFail(err)
}
fileAccess.AddDirs(extraDirs)
rules, err := loadRatchet(o.ratchet.off, root, &cfg)
if err != nil {
return configFail(err)
}
if rules != nil && rules.enforced > 0 {
if policy, err = permissions.New(cfg.Permissions); err != nil {
return configFail(err)
}
}
var reviewInfo *trajectory.ReviewInfo
if review != nil {
task, reviewInfo, err = collectReview(review, root, task, fileAccess, os.Stderr, o.verbose)
Expand Down Expand Up @@ -789,6 +803,7 @@ func run() int {
warn(trWarn)
}
startOutputFiles(outputFiles, cfg.ToolOutputFiles.MaxRuns, warn)
rules.start(a, events, warn)
if cfg.Skills {
loadSkills(a, root, pluginSkillSources(cfg), warn)
}
Expand Down
297 changes: 297 additions & 0 deletions cmd/tinyagent/ratchet_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
package main

import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"strings"
"time"

"github.com/vicgupta/tinyagent/internal/agent"
"github.com/vicgupta/tinyagent/internal/config"
"github.com/vicgupta/tinyagent/internal/permissions"
"github.com/vicgupta/tinyagent/internal/ratchet"
)

// ratchetFlags hold the project rules ("ratchet", issue #249) flags.
type ratchetFlags struct {
list, path bool
add, remove string
incident, expires, review string
denyTool, denyCommand, denyPath string
denyHost string
off bool
}

// ratchetCommandFlags are the management commands; exactly one may be set.
var ratchetCommandFlags = []string{"ratchet-list", "ratchet-path", "ratchet-add", "ratchet-remove"}

// ratchetAddFlags only apply to --ratchet-add.
var ratchetAddFlags = []string{"ratchet-incident", "ratchet-expires", "ratchet-review", "ratchet-deny-tool", "ratchet-deny-command", "ratchet-deny-path", "ratchet-deny-host"}

// ratchetGroup lists the ratchet flags in --help --verbose.
var ratchetGroup = flagGroup{"Project rules (ratchet)", [][3]string{
{"", "ratchet-add", "rule"}, {"", "ratchet-incident", "text"}, {"", "ratchet-expires", "date"}, {"", "ratchet-review", "text"},
{"", "ratchet-deny-tool", "glob"}, {"", "ratchet-deny-command", "glob"}, {"", "ratchet-deny-path", "pattern"}, {"", "ratchet-deny-host", "host"},
{"", "ratchet-list", ""}, {"", "ratchet-path", ""}, {"", "ratchet-remove", "id"}, {"", "no-ratchet", ""},
}}

func registerRatchetFlags(fs *flag.FlagSet, r *ratchetFlags) {
fs.StringVar(&r.add, "ratchet-add", "", "record a project rule with this text in --cwd's "+ratchet.RelPath+" and exit; needs --ratchet-incident; without --ratchet-expires or --ratchet-review it expires in 90 days")
fs.StringVar(&r.incident, "ratchet-incident", "", "with --ratchet-add: the failure that led to the rule (required)")
fs.StringVar(&r.expires, "ratchet-expires", "", "with --ratchet-add: YYYY-MM-DD date from which the rule is skipped and flagged as expired")
fs.StringVar(&r.review, "ratchet-review", "", "with --ratchet-add: when to reconsider the rule (e.g. \"when the default model changes\"); with it and no --ratchet-expires the rule has no expiry date")
fs.StringVar(&r.denyTool, "ratchet-deny-tool", "", "with --ratchet-add: also enforce the rule as a permissions deny rule for these tools (\"|\"-separated globs; defaults by matcher: bash|start_process, write_file|edit_file|apply_patch, web_fetch)")
fs.StringVar(&r.denyCommand, "ratchet-deny-command", "", "with --ratchet-add: also enforce the rule by denying shell commands matching this glob (e.g. \"git push *\")")
fs.StringVar(&r.denyPath, "ratchet-deny-path", "", "with --ratchet-add: also enforce the rule by denying file edits to paths matching this pattern (file_access syntax)")
fs.StringVar(&r.denyHost, "ratchet-deny-host", "", "with --ratchet-add: also enforce the rule by denying web_fetch to this host (sandbox_allowed_domains syntax)")
fs.BoolVar(&r.list, "ratchet-list", false, "print the project rules in --cwd's "+ratchet.RelPath+" with their status (active or expired), incident and expiry or review condition (--json for a JSON array), then exit")
fs.BoolVar(&r.path, "ratchet-path", false, "print the project rules file path for --cwd and exit")
fs.StringVar(&r.remove, "ratchet-remove", "", "delete the project rule with this id and exit")
fs.BoolVar(&r.off, "no-ratchet", false, "don't load the project rules in "+ratchet.RelPath+" for this run (no guidance, no enforced deny rules)")
}

// ratchetCommandSet reports whether a ratchet management flag was given.
func ratchetCommandSet(fs *flag.FlagSet) bool {
return flagWasSet(fs, ratchetCommandFlags...) || flagWasSet(fs, ratchetAddFlags...)
}

// ratchetAllowedFlags are the only other flags a ratchet command accepts.
var ratchetAllowedFlags = map[string]bool{"cwd": true, "json": true}

// ratchetNow is the clock for created/expiry dates; tests replace it.
var ratchetNow = time.Now

// runRatchetCommand implements the ratchet management flags. Usage errors
// exit 2; a rules file that can't be read or written exits 1.
func runRatchetCommand(o *options, fs *flag.FlagSet, stdout, stderr io.Writer) int {
var set []string
for _, name := range ratchetCommandFlags {
if flagWasSet(fs, name) {
set = append(set, "--"+name)
}
}
if len(set) == 0 {
fmt.Fprintln(stderr, "error: the --ratchet-incident, --ratchet-expires, --ratchet-review and --ratchet-deny-* flags apply only to --ratchet-add")
return 2
}
if len(set) > 1 {
fmt.Fprintf(stderr, "error: %s can't be combined; run one ratchet command at a time\n", strings.Join(set, " and "))
return 2
}
r := o.ratchet
var conflict string
fs.Visit(func(f *flag.Flag) {
if conflict != "" || ratchetAllowedFlags[f.Name] {
return
}
for _, n := range ratchetCommandFlags {
if f.Name == n {
return
}
}
for _, n := range ratchetAddFlags {
if f.Name == n && flagWasSet(fs, "ratchet-add") {
return
}
}
conflict = f.Name
})
if conflict != "" {
dash := "--"
if len(conflict) == 1 {
dash = "-"
}
fmt.Fprintf(stderr, "error: %s only manages the project rules file; it can't be combined with %s%s\n", set[0], dash, conflict)
return 2
}
if fs.NArg() > 0 {
fmt.Fprintf(stderr, "error: unexpected positional argument %q\n", fs.Arg(0))
return 2
}
if o.jsonOutput && !r.list {
fmt.Fprintln(stderr, "error: --json applies only to --ratchet-list among the ratchet commands")
return 2
}
if fi, err := os.Stat(o.cwd); err != nil || !fi.IsDir() {
fmt.Fprintf(stderr, "error: --cwd %q is not a directory\n", o.cwd)
return 2
}
root, err := canonicalRoot(o.cwd)
if err != nil {
fmt.Fprintln(stderr, "error: ratchet:", err)
return 1
}
path := ratchet.Path(root)
switch {
case r.path:
fmt.Fprintln(stdout, path)
return 0
case flagWasSet(fs, "ratchet-add"):
rule := ratchet.Rule{Rule: strings.TrimSpace(r.add), Incident: strings.TrimSpace(r.incident), Expires: r.expires, Review: strings.TrimSpace(r.review)}
if rule.Rule == "" {
fmt.Fprintln(stderr, "error: --ratchet-add needs the rule text")
return 2
}
if rule.Incident == "" {
fmt.Fprintln(stderr, "error: --ratchet-add needs --ratchet-incident: what went wrong that this rule prevents")
return 2
}
if r.denyTool != "" || r.denyCommand != "" || r.denyPath != "" || r.denyHost != "" {
rule.Deny = &ratchet.Deny{Tool: r.denyTool, Command: r.denyCommand, Path: r.denyPath, Host: r.denyHost}
}
probe := rule
probe.Created, probe.Expires = ratchetNow().Format(ratchet.DateLayout), "2000-01-01"
if rule.Expires != "" {
probe.Expires = rule.Expires
}
if err := probe.Validate(); err != nil {
fmt.Fprintln(stderr, "error: --ratchet-add:", err)
return 2
}
if rule.Deny != nil {
if err := checkRatchetDeny(rule); err != nil {
fmt.Fprintln(stderr, "error: --ratchet-add:", err)
return 2
}
}
added, err := ratchet.Add(path, rule, ratchetNow())
if err != nil {
fmt.Fprintln(stderr, "error: ratchet:", err)
return 1
}
fmt.Fprintf(stdout, "added rule %s to %s (%s)\n", added.ID, path, ratchetLimit(added))
return 0
case flagWasSet(fs, "ratchet-remove"):
id := strings.TrimSpace(r.remove)
if id == "" {
fmt.Fprintln(stderr, "error: --ratchet-remove needs a rule id (see --ratchet-list)")
return 2
}
if err := ratchet.Remove(path, id); err != nil {
fmt.Fprintln(stderr, "error: ratchet:", err)
return 1
}
fmt.Fprintf(stdout, "removed rule %s from %s\n", id, path)
return 0
}
rules, err := ratchet.Load(path)
if err != nil {
fmt.Fprintln(stderr, "error: ratchet:", err)
return 1
}
now := ratchetNow()
if o.jsonOutput {
out := []map[string]any{}
for _, x := range rules {
m := map[string]any{"id": x.ID, "rule": x.Rule, "incident": x.Incident, "created": x.Created, "status": ratchetStatus(x, now), "enforced": x.Deny != nil}
if x.Expires != "" {
m["expires"] = x.Expires
}
if x.Review != "" {
m["review"] = x.Review
}
if x.Deny != nil {
m["deny"] = ratchet.PermissionRules([]ratchet.Rule{x})[0]
}
out = append(out, m)
}
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
return 1
}
return 0
}
if len(rules) == 0 {
fmt.Fprintf(stdout, "no project rules (%s)\n", path)
return 0
}
for _, x := range rules {
enforced := ""
if x.Deny != nil {
enforced = " [enforced]"
}
fmt.Fprintf(stdout, "%s %s %s %s%s\n incident: %s\n %s\n", x.ID, ratchetStatus(x, now), x.Created, x.Rule, enforced, x.Incident, ratchetLimit(x))
}
return 0
}

func ratchetStatus(r ratchet.Rule, now time.Time) string {
if r.Expired(now) {
return "expired"
}
return "active"
}

func ratchetLimit(r ratchet.Rule) string {
var parts []string
if r.Expires != "" {
parts = append(parts, "expires "+r.Expires)
}
if r.Review != "" {
parts = append(parts, "review: "+r.Review)
}
return strings.Join(parts, "; ")
}

// checkRatchetDeny compiles a rule's deny matcher with the permission
// policy so a bad pattern is refused when it is recorded.
func checkRatchetDeny(r ratchet.Rule) error {
r.ID = "new"
_, err := permissions.New(config.Permissions{Rules: ratchet.PermissionRules([]ratchet.Rule{r})})
return err
}

// ratchetRun is the project rules a fresh run loaded.
type ratchetRun struct {
path string
active, expired []ratchet.Rule
enforced int
}

// loadRatchet reads root's project rules unless off, and appends a deny
// rule to cfg.Permissions for each active rule with a matcher. It returns
// nil when off or when the file is missing or empty, changing nothing.
func loadRatchet(off bool, root string, cfg *config.AgentConfig) (*ratchetRun, error) {
if off {
return nil, nil
}
path := ratchet.Path(root)
rules, err := ratchet.Load(path)
if err != nil {
return nil, &config.InvalidError{Err: fmt.Errorf("project rules: %w (fix the file, or run with --no-ratchet)", err)}
}
if len(rules) == 0 {
return nil, nil
}
rr := &ratchetRun{path: path}
rr.active, rr.expired = ratchet.Split(rules, ratchetNow())
deny := ratchet.PermissionRules(rr.active)
rr.enforced = len(deny)
cfg.Permissions.Rules = append(cfg.Permissions.Rules, deny...)
return rr, nil
}

// start puts the active rules in a's system prompt, warns about each
// expired rule, and emits ratchet.loaded.
func (rr *ratchetRun) start(a *agent.Agent, events *runEvents, warn func(string)) {
if rr == nil {
return
}
a.Ratchet = ratchet.Section(rr.active)
ids, expired := []string{}, []string{}
for _, r := range rr.active {
ids = append(ids, r.ID)
}
for _, r := range rr.expired {
expired = append(expired, r.ID)
warn(fmt.Sprintf("project rule %s expired on %s and was skipped; review it, then remove it (--ratchet-remove %s) or re-add it with a new expiry", r.ID, r.Expires, r.ID))
}
events.emit("ratchet.loaded", map[string]any{"path": rr.path, "loaded": len(rr.active), "ids": ids, "expired": expired, "enforced": rr.enforced})
if a.Verbose {
fmt.Fprintf(a.Out, "project rules: loaded %d (%d enforced, %d expired) from %s\n", len(rr.active), rr.enforced, len(rr.expired), rr.path)
}
}
Loading
Loading