Give a session a standing method, and stop cutting turns off at six

9b22493a7a97 · AtlantisPleb · · parent 779a77692f09

Give a session a standing method, and stop cutting turns off at six

Three things, from one transcript that read a package for twenty steps and
eighty thousand tokens and then said `Stopped after 6 rounds of tool calls
without an answer`.

**The ceiling was six.** It is a backstop against a model that loops forever,
not a budget, and six is a number real work passes. It is a hundred now, in both
lanes. A reader stops a turn with escape at any time, and that is the control
that should decide when enough is enough.

**Reaching it threw the work away.** The last round is now asked without tools
and with one instruction: answer with what you have, and say what is unfinished.
The model has one thing left it can do, which is report. Ending on "stopped"
discarded every read that made the turn long in the first place.

**A session had no standing context.** A skill can now set `auto: true`, and its
body goes in front of the first prompt rather than waiting to be asked for. A
session told to work a backlog needs the method before its first decision, and a
method it has to think to ask for is one it will not ask for. First prompt only:
after that it is in the transcript, and paying again each turn buys nothing.

`superdelegate` is that skill, and it replaces `delegating-work`. It carries the
lane choice, and the opinionated part: read the board before touching it, treat
blocked as not workable, one child per surface so two children cannot edit the
same files, route straightforward engineering to Devin and conceptual work to
opencode on Ox Alpha, and find the width rather than picking it — start near
four, look at load, memory, account status and rate limits, climb while they are
clean, and hold at the last clean width at the first sign of a limit.

A session in an OpenAgents directory is also told which repository is which:
`openagents.com` is the Phoenix web application and API, `openagents` is the
monorepo whose CLI lives in `packages/openagents-cli`. Both were being worked out
from scratch, repeatedly, by sessions with no way to know. Keyed on the path, so
a directory that is not one of them gets nothing rather than a guess.

Tracked as #22. 392 tests pass.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • deleted packages/openagents-cli/skills/delegating-work/SKILL.md
  • added packages/openagents-cli/skills/superdelegate/SKILL.md
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-ollama.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/src/coder-skills.ts
  • modified packages/openagents-cli/src/coder-thread.ts
  • modified packages/openagents-cli/test/coder-ollama.test.ts
  • modified packages/openagents-cli/test/coder-skills.test.ts

Diff

9 files changed, +309 -150

packages/openagents-cli/skills/delegating-work/SKILL.md deleted -112

@@ -1,112 +0,0 @@

1
---
2
name: delegating-work
3
description: Choose between running a command yourself, fanning work out to child coding agents with delegate, and handing a whole task to another agent such as the Devin CLI. Use it before reaching for delegate, and whenever work looks like it needs more than one worker.
4
---
5
6
# Handing work to something else
7
8
Three ways, in order of what they cost. Pick the cheapest that does the job.
9
10
## Do it yourself
11
12
The `shell` tool runs a command here. Reading a file, listing a directory,
13
searching, `git`, a build, a test run: all of it is one call and one process.
14
15
This is almost always the right answer for a single command. Starting an agent
16
to run `pwd` costs minutes and real money and hands back an answer nobody
17
watched being produced.
18
19
## `delegate`, for work that splits
20
21
The `delegate` tool starts child coding agents that all run the same prompt in
22
parallel, in this repository, each with its own file and shell tools. It earns
23
its cost when the work genuinely splits into parts that do not depend on each
24
other: several files to change the same way, several hypotheses to check at
25
once, several test suites to run down.
26
27
Each child starts with no context from this conversation and cannot ask
28
questions, so the prompt has to carry everything. Every child gets the same
29
prompt and is told its own number separately — write for whichever child is
30
reading, rather than naming one.
31
32
## `delegate --child-model devin`, for a Devin fan-out
33
34
If `devin` is on `PATH`, `delegate` can run its children on it instead of on
35
opencode: pass `--child-model devin`, or `devin:<mode>` for a permission mode
36
other than the default `dangerous`.
37
38
Prefer this over running `devin` yourself through `shell`. A child started this
39
way is a fleet child like any other — it reports through the registry the
40
interface renders, it can be stopped with `ctrl+x`, it does not block the turn
41
that started it, and several run at once. Run through `shell`, the same work is
42
one call that freezes the session and shows nothing until it ends.
43
44
A Devin child brings its own credentials and its own model rather than spending
45
this session's grant, which is a different trust and billing boundary from an
46
opencode child. That is why the fleet names the agent.
47
48
Its print mode has no structured output, so a Devin child reports its answer
49
once at the end rather than streaming its tool calls the way an opencode child
50
does.
51
52
## The Devin CLI directly, for one task you will wait on
53
54
If `devin` is on `PATH`, it is another coding agent on this machine, and it can
55
take a task end to end rather than one prompt in parallel. Check with
56
`command -v devin`.
57
58
Run it non-interactively through `shell`:
59
60
```sh
61
devin -p "<a complete, self-contained task>"                        # read-only
62
devin -p "<a complete, self-contained task>" --permission-mode dangerous  # unattended
63
```
64
65
`-p` means one prompt, print the answer, exit. What it may do is the permission
66
mode, and the default is already the read-only one:
67
68
| `--permission-mode` | Auto-approves |
69
| --- | --- |
70
| `auto` (default, no flag needed) | read-only tools only |
71
| `accept-edits` | and edits inside the workspace |
72
| `smart` | and anything a fast model judges safe |
73
| `dangerous` | everything, including writes and shell |
74
75
For a read-only run, pass no flag. In `-p` mode there is nobody to answer a
76
prompt, so anything not auto-approved simply does not happen — the mode is the
77
whole of the boundary. Run `devin --help` if this disagrees with the build in
78
front of you; the values it lists are the values it takes.
79
80
Three things are worth knowing before an unattended run.
81
82
**`dangerous` is the mode name on this build.** The published documentation
83
calls the equivalent mode "bypass". Passing `--permission-mode bypass` is not
84
rejected — it is accepted and ignored, so the session silently falls back to
85
prompting, and a prompt nobody can answer is a task that does nothing.
86
87
**It refuses a workspace nobody has opened it in**, exiting at once with
88
`Refusing to run in an untrusted workspace`. Print mode cannot show the trust
89
prompt, so it fails rather than asking. Pass `--respect-workspace-trust false`
90
to skip the check, which is what print mode is for; the alternative is someone
91
starting `devin` interactively in that directory once. Only that exact message
92
means trust — do not read other failures as a trust problem.
93
94
**`dangerous` auto-approves every tool it has.** That is the point of using it
95
unattended, and it is the reason to say what you are handing over before you
96
hand it over. Give it a bounded task in this repository, not an open-ended one.
97
98
There is also `devin acp`, an Agent Client Protocol server over stdio, for a
99
caller that speaks ACP. `-p` is the simpler route from here and needs no
100
protocol on this side.
101
102
## Which one
103
104
| The work | Use |
105
| --- | --- |
106
| One command, one answer | `shell` |
107
| The same thing to N independent parts, at once | `delegate` |
108
| A whole task you would otherwise do yourself, run by another agent | `devin -p` |
109
110
Whatever runs it, the result is yours to check. An agent reporting that it
111
finished is not evidence that it did; read the diff, run the test, look at the
112
output.
packages/openagents-cli/skills/superdelegate/SKILL.md added +105

@@ -0,0 +1,105 @@

1
---
2
name: superdelegate
3
description: How to hand work to something else, and how to burn through a backlog of open issues with maximum safe parallelism across Devin and opencode. Loaded into every session.
4
auto: true
5
---
6
7
# Handing work out
8
9
## Which lane
10
11
| The work | Use |
12
| --- | --- |
13
| One command, one answer | `shell` |
14
| The same thing to N independent parts | `delegate` |
15
| A backlog of issues | the method below |
16
17
Run a single command yourself. Starting an agent to run `pwd` costs minutes and
18
real money and hands back an answer nobody watched being produced.
19
20
`delegate` starts child coding agents that run the same prompt in parallel, each
21
with its own file and shell tools. Children start with no context from this
22
conversation and cannot ask questions, so the prompt carries everything. Every
23
child gets the same prompt and is told its own number separately — write for
24
whichever child is reading rather than naming one.
25
26
`--child-model devin` runs the children on the Devin CLI instead, and
27
`devin:<mode>` picks a permission mode other than the default `dangerous`. A
28
Devin child brings its own credentials and model rather than spending this
29
session's grant. Prefer this over running `devin` yourself through `shell`: a
30
fleet child renders, stops with `ctrl+x`, and does not block the turn, while a
31
shell child is one opaque call that freezes the session until it ends.
32
33
## Burning through a backlog
34
35
When the task is "work the issues" rather than one named thing, this is the
36
method. It is opinionated on purpose: the decisions below are the ones that go
37
wrong when they are made ad hoc.
38
39
### 1. Read the board before touching it
40
41
List open issues in every repository the account can reach —
42
`OpenAgentsInc/openagents` and `OpenAgentsInc/openagents.com` at least. Use the
43
`openagents` tool, read the plain output, and use `--json` only to take a field.
44
45
### 2. Workable means unblocked
46
47
`openagents issue view <n>` reports `Blocked` and `Blocked by`. **A blocked issue
48
is not workable**, however ready it looks, and starting one wastes a child and
49
produces a change that cannot land. Name the blocked ones as blocked, say what
50
they wait on, and leave them.
51
52
Prefer `agent-ready`. An issue whose shape is still a question is not ready for
53
a child that cannot ask one.
54
55
### 3. One child per surface
56
57
Two children editing the same files is a merge conflict nobody asked for. Group
58
the workable issues by the surface they touch — a package, a directory, a
59
module — and keep **one child in flight per surface**. Issues on different
60
surfaces run together freely.
61
62
If two issues must touch the same surface, run them in sequence, or give each
63
child its own git worktree so their edits cannot meet.
64
65
### 4. Route by the kind of thinking
66
67
- **Devin** for straightforward engineering: a named fix, a test to write, a
68
  migration, a rename, a documented change with a clear shape.
69
- **opencode on Ox Alpha** (`--child-model ox-alpha`) for high-concept and
70
  strategic work: design, architecture, anything where the shape of the answer
71
  is the question.
72
73
If you cannot tell which, it is the second kind.
74
75
### 5. Find the width, do not pick it
76
77
**Never choose a number and hope.** Start at about four and climb:
78
79
1. Run a round of four.
80
2. Before widening, look: system load and free memory
81
   (`uptime`, `vm_stat` or `free`), `openagents auth status` for the account,
82
   and whether any child came back with a rate limit, a quota refusal, or a
83
   provider error.
84
3. If all three are clean, raise the width — four, six, eight — and go again.
85
4. At the **first** sign of a limit, stop raising and hold at the last width
86
   that was clean. Do not push past it to confirm; the confirmation costs a
87
   round of failed children.
88
89
The width is a measurement, not a setting. A machine with sixteen idle cores and
90
a healthy account should be running far more than four; one that is swapping
91
should be running fewer. Report the width you reached and what stopped it.
92
93
### 6. Report as you go
94
95
After each round, say which issues were selected, which were skipped and why,
96
what each child returned, and where the width stands. A backlog run that reports
97
only at the end is one nobody can steer.
98
99
## What is not yours to decide
100
101
Closing an issue, pushing, and commenting are visible to other people at once.
102
Do the work and report it; ask before the first write that leaves the machine.
103
104
An agent reporting that it finished is not evidence that it did. Read the diff,
105
run the test, look at the output.
packages/openagents-cli/src/cli.ts modified +3 -2

@@ -39,7 +39,7 @@ import { delegateTool, openagentsTool, shellTool, skillTool } from "./coder-tool

39 39
import { spawnSync } from "node:child_process";
40 40
41 41
import { rebuild, RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";
42
import { loadSkillSelection } from "./coder-skills.js";
42
import { loadSkillSelection, standingContext } from "./coder-skills.js";
43 43
import { describeWorkspace } from "./coder-workspace.js";
44 44
import { ComputerClient } from "./computer-client.js";
45 45
import { ComputerUp } from "./computer-up.js";

@@ -1767,11 +1767,13 @@ const coderCommand = Command.make(

1767 1767
        }),
1768 1768
      );
1769 1769
1770
      const skills = loadSkillSelection();
1770 1771
      const session = new CoderSession(
1771 1772
        source,
1772 1773
        workspace.repository,
1773 1774
        workspace.branch,
1774 1775
        setup?.delegation,
1776
        standingContext(skills.active(), process.cwd()),
1775 1777
      );
1776 1778
1777 1779
      // The model is told what it can do rather than the reader being asked to

@@ -1781,7 +1783,6 @@ const coderCommand = Command.make(

1781 1783
      // Skills do not depend on delegation: a session with no credential still
1782 1784
      // reads this repository's conventions, it just cannot hand work to a
1783 1785
      // child. A session with neither declares no tools at all.
1784
      const skills = loadSkillSelection();
1785 1786
      // Re-declared rather than declared once: switching a skill off in
1786 1787
      // `/skills` has to change what the next turn carries, and the tool
1787 1788
      // holding the catalog is the thing that changes.
packages/openagents-cli/src/coder-ollama.ts modified +23 -12

@@ -20,13 +20,16 @@ import type { CoderTool } from "./coder-tools.js";

20 20
const DEFAULT_HOST = "http://127.0.0.1:11434";
21 21
22 22
/**
23
 * How many times one turn may call tools before it has to answer.
23
 * How many rounds of tool calls one turn may take before it has to answer.
24 24
 *
25
 * The same ceiling the thread lane uses, for the same reason: a model that
26
 * keeps delegating never reports to the reader. A local model spends no metered
27
 * budget, but it does spend the reader's wall clock and the children's.
25
 * High, because the ceiling is a backstop against a model that loops forever,
26
 * not a budget. It was six, and six is a number real work passes: a session
27
 * reading a package hit it after twenty steps and eighty thousand tokens and
28
 * ended with `Stopped after 6 rounds of tool calls without an answer` — every
29
 * one of those reads thrown away. A reader can stop a turn with escape at any
30
 * time, and that is the control that should decide when enough is enough.
28 31
 */
29
const MAX_TOOL_STEPS = 6;
32
const MAX_TOOL_STEPS = 100;
30 33
31 34
export interface OllamaOptions {
32 35
  /** The Ollama model name, without the `ollama:` prefix. */

@@ -225,6 +228,20 @@ export class OllamaReplySource implements ReplySource {

225 228
      const calls: OllamaToolCall[] = [];
226 229
      let assistant = "";
227 230
231
      // The last round is answered without tools. Reaching the ceiling with the
232
      // tools still on the table produced a turn that stopped mid-work and said
233
      // so, throwing away everything it had read; taking them away instead
234
      // leaves the model one thing it can do, which is report what it found.
235
      const finalRound = step === MAX_TOOL_STEPS - 1;
236
      if (finalRound && this.tools.length > 0) {
237
        this.transcript.push({
238
          role: "user",
239
          content:
240
            "You have reached this turn's limit on tool calls. Do not call another tool. " +
241
            "Answer now with what you have found, and say plainly what is still unfinished.",
242
        });
243
      }
244
228 245
      const stream = await this.client.chat({
229 246
        model: this.modelName,
230 247
        // A snapshot, not the live array: the transcript grows while the round

@@ -232,7 +249,7 @@ export class OllamaReplySource implements ReplySource {

232 249
        // request nobody can reason about.
233 250
        messages: [...this.transcript],
234 251
        stream: true,
235
        ...(this.tools.length === 0
252
        ...(this.tools.length === 0 || finalRound
236 253
          ? {}
237 254
          : {
238 255
              tools: this.tools.map((tool) => ({

@@ -306,12 +323,6 @@ export class OllamaReplySource implements ReplySource {

306 323
      }
307 324
    }
308 325
309
    // The ceiling was reached. Say so rather than ending on a tool result the
310
    // reader has to interpret as an answer.
311
    yield {
312
      type: "text",
313
      value: `\n\nStopped after ${String(MAX_TOOL_STEPS)} rounds of tool calls without an answer.`,
314
    };
315 326
    yield { type: "usage", promptTokens, completionTokens, calls: llmCalls };
316 327
  }
317 328
packages/openagents-cli/src/coder-session.ts modified +17 -1

@@ -311,6 +311,15 @@ export class CoderSession {

311 311
    private readonly repository: string,
312 312
    private readonly branch: string,
313 313
    private readonly delegation?: CoderDelegation,
314
    /**
315
     * Put in front of the first prompt, and nowhere else.
316
     *
317
     * A session told how to approach its work needs that before its first
318
     * decision. It goes ahead of the first turn rather than into every one:
319
     * after that it is in the transcript, and paying for it again each turn
320
     * buys nothing.
321
     */
322
    private readonly standing?: string,
314 323
  ) {
315 324
    // A child reporting progress has to reach the renderer, and the renderer
316 325
    // subscribes to the session rather than to the registry, so the session

@@ -491,7 +500,14 @@ export class CoderSession {

491 500
    this.emit();
492 501
493 502
    try {
494
      for await (const chunk of this.source.reply(prompt, controller.signal)) {
503
      // The reader's entry above keeps what they typed; the model receives the
504
      // standing context ahead of it on the first turn only.
505
      const sent =
506
        this.standing === undefined || this.turnCount > 1
507
          ? prompt
508
          : `${this.standing}\n\n---\n\n${prompt}`;
509
510
      for await (const chunk of this.source.reply(sent, controller.signal)) {
495 511
        if (controller.signal.aborted) break;
496 512
497 513
        if (chunk.type === "text") {
packages/openagents-cli/src/coder-skills.ts modified +79 -4

@@ -51,6 +51,18 @@ const BODY_LIMIT = 32_000;

51 51
export interface CoderSkill {
52 52
  /** The name the model asks for, from the front matter. */
53 53
  readonly name: string;
54
  /**
55
   * Whether the body is put in front of the model without being asked for.
56
   *
57
   * The catalog exists so a body is read only when it is wanted. A skill that
58
   * says how to approach the work is the exception: a session told to burn
59
   * through a backlog needs the method before its first decision, and a skill
60
   * it has to think to ask for is one it will not ask for.
61
   *
62
   * Set with `auto: true` in the front matter. Use it sparingly — every
63
   * auto-loaded body is paid for on every turn.
64
   */
65
  readonly auto: boolean;
54 66
  /** When to use it, from the front matter. One sentence, shown in the catalog. */
55 67
  readonly description: string;
56 68
  /** The instructions, front matter removed. */

@@ -67,15 +79,20 @@ export interface CoderSkill {

67 79
 * is a dependency that can also do something surprising with a file anyone may
68 80
 * drop in a skills directory.
69 81
 */
70
const frontMatter = (source: string): { name?: string; description?: string } => {
82
const frontMatter = (source: string): { name?: string; description?: string; auto?: boolean } => {
71 83
  if (!source.startsWith("---")) return {};
72 84
  const end = source.indexOf("\n---", 3);
73 85
  if (end < 0) return {};
74 86
75
  const fields: { name?: string; description?: string } = {};
87
  const fields: { name?: string; description?: string; auto?: boolean } = {};
76 88
  const lines = source.slice(3, end).split("\n");
77 89
78 90
  for (const [at, line] of lines.entries()) {
91
    const auto = /^auto:\s*(true|false)\s*$/.exec(line);
92
    if (auto !== null) {
93
      fields.auto = auto[1] === "true";
94
      continue;
95
    }
79 96
    const match = /^(name|description):\s*(.*)$/.exec(line);
80 97
    if (match === null) continue;
81 98
    const key = match[1] as "name" | "description";

@@ -145,13 +162,19 @@ export function discoverSkills(

145 162
        continue;
146 163
      }
147 164
148
      const { name, description } = frontMatter(source);
165
      const { name, description, auto } = frontMatter(source);
149 166
      // A skill with no name cannot be asked for, and one with no description
150 167
      // gives the model nothing to choose on. Both are required.
151 168
      if (name === undefined || description === undefined) continue;
152 169
      if (found.has(name)) continue;
153 170
154
      found.set(name, { name, description, body: withoutFrontMatter(source), path });
171
      found.set(name, {
172
        name,
173
        description,
174
        auto: auto ?? false,
175
        body: withoutFrontMatter(source),
176
        path,
177
      });
155 178
    }
156 179
  }
157 180

@@ -264,3 +287,55 @@ export function loadSkillSelection(

264 287
    active: () => all.filter((skill) => !disabled.has(skill.name)),
265 288
  };
266 289
}
290
291
/**
292
 * The standing context for a session: every auto-loaded skill, in one block.
293
 *
294
 * Returns undefined when there is none, so a caller adds nothing rather than an
295
 * empty heading.
296
 */
297
export const standingContext = (
298
  skills: ReadonlyArray<CoderSkill>,
299
  cwd: string = process.cwd(),
300
): string | undefined => {
301
  const parts: string[] = [];
302
303
  const workspace = openAgentsWorkspace(cwd);
304
  if (workspace !== undefined) parts.push(workspace);
305
306
  for (const skill of skills) {
307
    if (!skill.auto) continue;
308
    parts.push(`The \`${skill.name}\` skill, which applies to this session:\n\n${skill.body}`);
309
  }
310
311
  return parts.length === 0 ? undefined : parts.join("\n\n");
312
};
313
314
/**
315
 * What the two OpenAgents repositories are, when the session is in one.
316
 *
317
 * A session in `openagents.com` spent turns working out that it was in the
318
 * Phoenix application, and one in `openagents` that the CLI lives under
319
 * `packages/`. Both are facts about the workspace rather than about the work,
320
 * and neither is discoverable without reading around.
321
 *
322
 * Keyed on the path because that is what is true at the time: a directory named
323
 * for one of them is one of them, and a directory that is not gets nothing
324
 * rather than a guess.
325
 */
326
const openAgentsWorkspace = (cwd: string): string | undefined => {
327
  if (!/openagents/i.test(cwd)) return undefined;
328
  return [
329
    `This session is working in ${cwd}, which is part of OpenAgents. Two repositories carry`,
330
    "most of the work, and they are easy to confuse:",
331
    "",
332
    "- **`openagents.com`** is the web application: a Phoenix and Elixir codebase serving the",
333
    "  site, the forge, and the `/api/v3` API. Its issues are the site's issues.",
334
    "- **`openagents`** is the monorepo: the `openagents` CLI lives in",
335
    "  `packages/openagents-cli`, alongside the other packages. Its issues are the CLI's and the",
336
    "  monorepo's.",
337
    "",
338
    "They are separate repositories with separate issue lists, so name the one you mean when you",
339
    "read or write issues, and do not assume the current directory is the one being asked about.",
340
  ].join("\n");
341
};
packages/openagents-cli/src/coder-thread.ts modified +24 -10

@@ -69,12 +69,15 @@ import type { CoderTool } from "./coder-tools.js";

69 69
const THREADS_PATH = "/api/v3/threads";
70 70
71 71
/**
72
 * How many times one turn may call tools before it has to answer.
72
 * How many rounds of tool calls one turn may take before it has to answer.
73 73
 *
74
 * A ceiling rather than a preference: a model that keeps delegating is a model
75
 * spending the thread's budget without ever reporting to the reader.
74
 * A backstop against a model that loops forever, not a budget. It was six, and
75
 * six is a number real work passes: a session reading a package hit it after
76
 * twenty steps and ended saying it had stopped, throwing away everything it had
77
 * read. Escape stops a turn at any time, and that is the control that should
78
 * decide when enough is enough.
76 79
 */
77
const MAX_TOOL_STEPS = 6;
80
const MAX_TOOL_STEPS = 100;
78 81
79 82
/** What the thread may still spend, as the server last reported it. */
80 83
export interface ThreadBudget {

@@ -223,6 +226,8 @@ export class ThreadReplySource implements ReplySource {

223 226
  private readonly transcript: WireMessage[] = [];
224 227
  private remaining: ThreadBudget;
225 228
  private tools: ReadonlyArray<CoderTool> = [];
229
  /** Set for the one round that must answer rather than call another tool. */
230
  private mustAnswer = false;
226 231
227 232
  constructor(private readonly state: SourceState) {
228 233
    this.threadId = state.threadId;

@@ -309,6 +314,9 @@ export class ThreadReplySource implements ReplySource {

309 314
  }
310 315
311 316
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
317
    // Per turn, not per session: a turn that had to answer without tools must
318
    // not leave the next one without them.
319
    this.mustAnswer = false;
312 320
    this.transcript.push({ role: "user", content: prompt });
313 321
314 322
    try {

@@ -329,11 +337,17 @@ export class ThreadReplySource implements ReplySource {

329 337
        if (signal.aborted || calls.length === 0) return;
330 338
331 339
        if (step >= MAX_TOOL_STEPS) {
332
          yield {
333
            type: "text",
334
            value: `\n\n[stopped after ${String(MAX_TOOL_STEPS)} tool steps in one turn]`,
335
          };
336
          return;
340
          // Take the tools away for one more round rather than stopping on a
341
          // tool result. The work already done is the reason the turn is long,
342
          // and ending on "stopped" throws all of it away.
343
          this.mustAnswer = true;
344
          this.transcript.push({
345
            role: "user",
346
            content:
347
              "You have reached this turn's limit on tool calls. Do not call another tool. " +
348
              "Answer now with what you have found, and say plainly what is still unfinished.",
349
          });
350
          continue;
337 351
        }
338 352
339 353
        for (const call of calls) {

@@ -434,7 +448,7 @@ export class ThreadReplySource implements ReplySource {

434 448
        model: this.state.model,
435 449
        stream: true,
436 450
        messages: this.transcript,
437
        ...(this.tools.length === 0
451
        ...(this.tools.length === 0 || this.mustAnswer
438 452
          ? {}
439 453
          : {
440 454
              tools: this.tools.map((tool) => ({
packages/openagents-cli/test/coder-ollama.test.ts modified +17 -8

@@ -200,18 +200,27 @@ describe("an ollama turn that calls a tool", () => {

200 200
    });
201 201
  });
202 202
203
  it("stops after the tool-call ceiling rather than looping forever", async () => {
204
    const rounds = Array.from({ length: 8 }, () => CALLING);
203
  it("answers on the last round instead of stopping mid-work", async () => {
204
    // Always asking for a tool, so the ceiling is what ends the turn. The
205
    // ceiling is a backstop against looping forever, not a budget: reaching it
206
    // used to end with "Stopped after 6 rounds" and throw away every read.
207
    const rounds = Array.from({ length: 200 }, () => CALLING);
205 208
    const { source, stub } = sourceWith(rounds);
206 209
    source.useTools([delegate([])]);
207 210
208
    const chunks = await collect(source, "keep going");
211
    await collect(source, "keep going");
209 212
210
    // Six rounds, then a sentence saying why it stopped.
211
    expect(stub.chat).toHaveBeenCalledTimes(6);
212
    const spoken = chunks.filter((piece) => piece.type !== "usage");
213
    expect(spoken.at(-1)).toMatchObject({ type: "text" });
214
    expect((spoken.at(-1) as { value: string }).value).toContain("Stopped after 6 rounds");
213
    expect(stub.chat).toHaveBeenCalledTimes(100);
214
215
    // The last round is asked without tools, so the model has one thing left it
216
    // can do: report what it found.
217
    const last = stub.requests.at(-1);
218
    expect(last).not.toHaveProperty("tools");
219
    const messages = last?.["messages"] as ReadonlyArray<Record<string, unknown>>;
220
    expect(messages.at(-1)).toMatchObject({
221
      role: "user",
222
      content: expect.stringContaining("Do not call another tool"),
223
    });
215 224
  });
216 225
});
217 226
packages/openagents-cli/test/coder-skills.test.ts modified +41 -1

@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";

3 3
import { join } from "node:path";
4 4
import { describe, expect, it } from "vitest";
5 5
6
import { discoverSkills, loadSkillSelection } from "../src/coder-skills.js";
6
import { discoverSkills, loadSkillSelection, standingContext } from "../src/coder-skills.js";
7 7
import { skillTool } from "../src/coder-tools.js";
8 8
9 9
/** A repository with the given skills under `.agents/skills`. */

@@ -262,3 +262,43 @@ describe("the skills the CLI ships with", () => {

262 262
    expect(cli?.body).toContain("auth login --headless");
263 263
  });
264 264
});
265
266
describe("what a session is told without asking", () => {
267
  const AUTO = "---\nname: method\ndescription: How to work.\nauto: true\n---\n\nWork this way.";
268
  const NORMAL = "---\nname: other\ndescription: Something else.\n---\n\nRead me on request.";
269
270
  it("marks a skill that loads itself", () => {
271
    const found = discoverSkills(workspace({ method: AUTO, other: NORMAL }), EMPTY_HOME, NO_BUILT_INS);
272
273
    expect(found.find((skill) => skill.name === "method")?.auto).toBe(true);
274
    expect(found.find((skill) => skill.name === "other")?.auto).toBe(false);
275
  });
276
277
  it("carries an auto-loaded body and leaves the rest in the catalog", () => {
278
    const found = discoverSkills(workspace({ method: AUTO, other: NORMAL }), EMPTY_HOME, NO_BUILT_INS);
279
280
    const standing = standingContext(found, "/somewhere/else") ?? "";
281
    expect(standing).toContain("Work this way.");
282
    // The whole point of the catalog is that a body is read when it is wanted.
283
    expect(standing).not.toContain("Read me on request.");
284
  });
285
286
  it("says nothing when nothing loads itself", () => {
287
    const found = discoverSkills(workspace({ other: NORMAL }), EMPTY_HOME, NO_BUILT_INS);
288
289
    expect(standingContext(found, "/somewhere/else")).toBeUndefined();
290
  });
291
292
  it("tells a session inside OpenAgents which repository is which", () => {
293
    // Both were worked out from scratch, repeatedly, by sessions that had no
294
    // way to know: one is Phoenix, the other holds the CLI.
295
    const standing = standingContext([], "/Users/x/work/openagents.com") ?? "";
296
297
    expect(standing).toContain("Phoenix");
298
    expect(standing).toContain("packages/openagents-cli");
299
  });
300
301
  it("says nothing about repositories anywhere else", () => {
302
    expect(standingContext([], "/Users/x/work/something")).toBeUndefined();
303
  });
304
});

This page updates live while a promote is in flight · changelog