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
56 changes: 53 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ chatchain [openai|anthropic|gemini|vertexai|openresponses] [flags]
| `--mcp` | | MCP server (command string or URL, repeatable) |
| `--resume` | | Resume a saved session (`--resume` to pick interactively, `--resume=<id>` for a specific one) |
| `--no-save` | | Start ephemeral — nothing touches disk unless `/save` is run |
| `--max-turns` | | Limit agentic tool turns in non-interactive mode (`-m` only; 0 = unlimited) |
| `--max-turns` | | Limit agentic tool turns for the whole run, delegated children included (`-m` only; 0 = unlimited) |
| `--output-format` | | `-m` output: `text` (default, the reply alone) or `json` (one result object with per-round token usage) |
| `--context-window` | | Context window size for compaction accounting (e.g. `200k`, `1m`; default 128k) |
| `--agent` | | Enable agent mode (AGENTS.md overlay, skills, `load_skill`, project-scoped sessions) |
Expand Down Expand Up @@ -321,8 +321,9 @@ enabled by listing it under that provider's `tools:` key; the value is the
set's shared configuration, and an empty value uses its defaults. Available
sets: `shell` (running bash commands, sandboxed), `code` (reading, searching,
and editing project files), `agent` (skill activation; auto-enabled by agent
mode), and `ask` (interactive questions to the user; enabled by default in
interactive sessions — disable with `ask: false`).
mode), `ask` (interactive questions to the user; enabled by default in
interactive sessions — disable with `ask: false`), and `delegate` (running a
task as a child agent).

```yaml
providers:
Expand Down Expand Up @@ -355,6 +356,50 @@ single yes/no. ESC declines — the model is told and proceeds on its own.
Zero side effects, on by default interactively, absent in `-m` runs; opt out
per provider with `tools: {ask: false}`.

#### `delegate` — `delegate`

Runs a task as a **child agent**: its own context, its own tool loop, and
only its final answer comes back — no tool calls, no reasoning. A survey that
takes twenty rounds costs this conversation one paragraph.

An agent is a name for a provider entry you already have, so nothing about
the child is configured twice — its model, tools, approval settings, system
prompt and sampling all come from the entry it names:

```yaml
tools:
delegate:
max_turns: 30 # optional per-child cap; default unlimited
agents:
search: fast-provider
review:
provider: careful-provider
description: Reads a diff and reports what is wrong with it
```

`description` is the only field that is not already over there, and it is
what the model chooses between agents on. Image settings (`image`,
`json_edits`, `aspect_ratio`, `image_size`, `negative_prompt`) are the one
part a child does not adopt — a delegation returns text, and an image a child
generated would land on disk where the parent never learns of it.

In `-m` runs `--max-turns` is a budget for the whole run: the parent and every
child it delegates to draw on one pool, so the number bounds what the run can
cost rather than what each agent can. Interactive runs have no cap — ESC
cancels a delegation the same as anything else.

`--output-format json` reports what the children cost under `delegated`,
beside the parent's own `usage` rather than inside it: one figure says what
this agent spent, the other what it spent by delegating.

Delegations to an agent whose tools cannot change state run **concurrently**;
anything else runs one at a time. A child's write requests surface as an
approval prompt in your terminal, labelled with the agent that asked
(`review › edit_file wants to modify files`), and "allow for this session"
covers parent and children alike. What a child cost appears beside its call
and is never added to this conversation's context. A child is never given
`delegate` itself.

#### `shell` — `bash`

Lets the model run real bash command lines — pipes, redirects, `&&` chaining,
Expand Down Expand Up @@ -405,6 +450,11 @@ Safety model:
auto_write: true # optional; default asks before every write
```

Or withhold the writers entirely with `read_only: true`, leaving `glob`,
`grep`, `list_dir` and `read_file`. A tool the model cannot see is never
attempted and never refused — useful for a reviewer, and required for a
`delegate` agent that should search and still run concurrently.

Design: docs/design/code-toolset.md

### Agent Mode
Expand Down
91 changes: 91 additions & 0 deletions chat/approval.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package chat

import (
"context"
"fmt"
"strings"

"chatchain/internal/host"
"chatchain/internal/ui"
"chatchain/tool"
)

// approvalGate is the one place a state-changing call is put to the user.
//
// Both the conversation's own tool loop and a delegated child arrive here. A
// child has no terminal of its own, and giving it a second gate would mean
// two prompts with two memories in front of one person — so its questions
// travel up to this one instead, labelled with the agent that asked.
//
// The "allow for this session" memory is shared for the same reason: the
// grant a user gives is "this session may edit files", and a child running
// inside the session is part of it. What the prompt shows is symmetric too —
// the tool and the file, never the diff, which settles only after the call —
// so a parent's request and a child's are answered on the same evidence.
type approvalGate struct {
u *ui.UI
tr *transcript
pres *host.Presenter
approved map[string]bool // "allow for this session", keyed by tool name
}

// ask resolves one gated call.
//
// detail is what the call is about — the path it writes, the command it runs.
// Without it the prompt named only the tool, which for a delegated call left
// nothing on screen to identify the operation: the widget above describes the
// DELEGATION, not what the child is asking to do. Approving "edit_file" with
// no idea which file is not consent.
//
// subject names where the request came from when it was not this conversation
// ("search" for a delegation); empty for the conversation's own calls, whose
// origin needs no saying.
//
// The error is reserved for the prompt itself failing. A refusal is (false,
// nil): the caller turns it into a result the model can read, and a turn that
// continues after a denial is the point of asking.
func (g *approvalGate) ask(ctx context.Context, name, detail, subject string) (bool, error) {
if g.approved[name] {
return true, nil
}
label := displayToolName(name)
if detail != "" {
label += " " + detail
}
if subject != "" {
label = subject + " › " + label
}
// The turn is now blocked on the user: needs-input state on every host,
// and a ping if they wandered off.
g.pres.SetState(host.StateNeedsInput)
g.pres.Notify(host.Event{Kind: host.KindNeedsInput,
Text: fmt.Sprintf("%s wants to modify files", label)})
g.tr.pauseForInput("waiting for approval")
choice, err := g.u.Select(ctx, ui.SelectSpec{
Title: fmt.Sprintf("%s wants to modify files — allow?", label),
Items: []string{"Allow once", "Allow for this session", "Deny"},
})
g.tr.resumeFromInput()
g.pres.SetState(host.StateBusy) // resolved either way; end states override
if err != nil {
return false, err
}
if choice.Cancelled || choice.Index == 2 {
return false, nil
}
if choice.Index == 1 {
g.approved[name] = true
}
return true, nil
}

// artifactNote renders a "note" artifact into the event row's trailing
// detail: a short fact about the call meant for the user and withheld from
// the model. The "diff" kind belongs to the expanded path and is ignored
// here — one side channel, read differently by the two renderers.
func artifactNote(art *tool.Artifact) string {
if art == nil || art.Kind != "note" || len(art.Lines) == 0 {
return ""
}
return strings.Join(art.Lines, " · ")
}
186 changes: 186 additions & 0 deletions chat/approval_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package chat

import (
"context"
"errors"
"fmt"
"io"
"strings"
"testing"

"chatchain/provider"
)

// gatedDispatch owns one tool that always needs approval, and records whether
// it was ever actually executed.
type gatedDispatch struct{ ran int }

func (d *gatedDispatch) Tools() []provider.ToolDef {
return []provider.ToolDef{{Name: "write_file"}}
}
func (d *gatedDispatch) RequiresApproval(string) bool { return true }
func (d *gatedDispatch) CallTool(context.Context, string, map[string]any) (string, bool, error) {
d.ran++
return "written", false, nil
}

// writingProvider asks for the gated tool once, then answers.
type writingProvider struct{ calls int }

func (p *writingProvider) StreamChatWithTools(ctx context.Context, msgs []provider.Message, tools []provider.ToolDef, w io.Writer, reasoning io.WriteCloser) (string, string, []provider.ToolCall, error) {
reasoning.Close()
p.calls++
if p.calls == 1 {
return "", "", []provider.ToolCall{{ID: "c1", Name: "write_file", Arguments: map[string]any{}}}, nil
}
// The result of the gated call is the last thing in history; echo it so
// the test can assert on what the model was actually told.
last := msgs[len(msgs)-1]
return "saw: " + last.Content, "", nil, nil
}

func runGated(t *testing.T, host quietHost) (string, *gatedDispatch, error) {
t.Helper()
d := &gatedDispatch{}
history := []provider.Message{{Role: "user", Content: "go"}}
reply, _, err := executeWithTools(context.Background(), &writingProvider{}, d,
&history, d.Tools(), "", 0, host)
return reply, d, err
}

// With nobody to ask, the loop refuses and says how to enable the call. That
// is the -m contract and it must survive the seam being added.
func TestQuietLoopRefusesWhenThereIsNobodyToAsk(t *testing.T) {
reply, d, err := runGated(t, quietHost{rec: newRunRecorder()})
if err != nil {
t.Fatal(err)
}
if d.ran != 0 {
t.Error("a gated tool ran with no approval")
}
if !strings.Contains(reply, "auto_write") {
t.Errorf("the refusal must name the way to enable it, got %q", reply)
}
}

// A delegated child has no user of its own but runs inside a parent that does,
// so its question travels up and the answer decides the call.
func TestQuietLoopForwardsApproval(t *testing.T) {
var asked []string
host := quietHost{rec: newRunRecorder(),
approve: func(_ context.Context, tc provider.ToolCall, _ string) (bool, string) {
asked = append(asked, tc.Name)
return true, ""
}}
reply, d, err := runGated(t, host)
if err != nil {
t.Fatal(err)
}
if len(asked) != 1 || asked[0] != "write_file" {
t.Errorf("gate saw %v, want one write_file", asked)
}
if d.ran != 1 {
t.Errorf("tool ran %d times, want 1", d.ran)
}
if !strings.Contains(reply, "written") {
t.Errorf("the model should have seen the result, got %q", reply)
}
}

// A denial is a result the model reads, not an aborted turn: the child carries
// on and reports back, which is what the parent's terminal showed.
func TestQuietLoopDenialContinuesTheRun(t *testing.T) {
host := quietHost{rec: newRunRecorder(),
approve: func(_ context.Context, tc provider.ToolCall, _ string) (bool, string) {
return false, "The user declined this call."
}}
reply, d, err := runGated(t, host)
if err != nil {
t.Fatal(err)
}
if d.ran != 0 {
t.Error("a denied tool ran anyway")
}
if !strings.Contains(reply, "declined") {
t.Errorf("the model must be told it was declined, got %q", reply)
}
}

// The refusal text is the call's result either way, so a failing prompt must
// not be mistaken for consent.
func TestQuietHostAskApprovalShapes(t *testing.T) {
var h quietHost
ok, why := h.askApproval(context.Background(), provider.ToolCall{Name: "edit_file"}, "path:x")
if ok || !strings.Contains(why, "edit_file") {
t.Errorf("nil approver = (%v, %q)", ok, why)
}
h.approve = func(_ context.Context, tc provider.ToolCall, _ string) (bool, string) {
return false, fmt.Sprintf("%v", errors.New("prompt broke"))
}
if ok, why := h.askApproval(context.Background(), provider.ToolCall{Name: "edit_file"}, "path:x"); ok || why != "prompt broke" {
t.Errorf("failed prompt = (%v, %q), want a refusal carrying the reason", ok, why)
}
}

// detailDispatch names the file its gated tool would write, the way the code
// set's headliner does.
type detailDispatch struct{ gatedDispatch }

func (detailDispatch) HeaderSummary(_ string, args map[string]any) (string, bool) {
p, _ := args["path"].(string)
return p, true
}

// The prompt has to say what the call is ABOUT. For a delegated call nothing
// else on screen does: the widget above describes the delegation, not the
// operation the child is asking to perform, so a gate naming only the tool
// asks the user to authorize "edit_file" without saying which file.
func TestForwardedApprovalCarriesTheCallDetail(t *testing.T) {
var seen []string
host := quietHost{rec: newRunRecorder(),
approve: func(_ context.Context, _ provider.ToolCall, detail string) (bool, string) {
seen = append(seen, detail)
return false, "The user declined this call."
}}
d := &detailDispatch{}
tp := &pathWritingProvider{}
history := []provider.Message{{Role: "user", Content: "go"}}
if _, _, err := executeWithTools(context.Background(), tp, d, &history,
d.Tools(), "", 0, host); err != nil {
t.Fatal(err)
}
if len(seen) != 1 || seen[0] != "internal/ui/model.go" {
t.Errorf("gate saw detail %v, want the path the call would write", seen)
}
}

type pathWritingProvider struct{ calls int }

func (p *pathWritingProvider) StreamChatWithTools(ctx context.Context, msgs []provider.Message, tools []provider.ToolDef, w io.Writer, reasoning io.WriteCloser) (string, string, []provider.ToolCall, error) {
reasoning.Close()
p.calls++
if p.calls == 1 {
return "", "", []provider.ToolCall{{ID: "c1", Name: "write_file",
Arguments: map[string]any{"path": "internal/ui/model.go"}}}, nil
}
return "done", "", nil, nil
}

// The header keeps its shape after the detail was split out of it — one
// implementation, two readers.
func TestToolCallHeaderUnchangedBySplit(t *testing.T) {
d := &detailDispatch{}
tc := provider.ToolCall{Name: "write_file", Arguments: map[string]any{"path": "a/b.go"}}
if got := toolCallHeader(d, tc); got != "[write_file a/b.go]" {
t.Errorf("header = %q", got)
}
// A tool with no summary of its own falls back to the argument digest,
// and an empty summary renders as a bare name rather than the digest.
plain := &gatedDispatch{}
if got := toolCallHeader(plain, tc); got != "[write_file path:a/b.go]" {
t.Errorf("digest header = %q", got)
}
if got := toolCallHeader(plain, provider.ToolCall{Name: "x"}); got != "[x]" {
t.Errorf("bare header = %q", got)
}
}
Loading
Loading