docs: Created documentation on the new background job subsystem

2026-08-26 14:54:55 -06:00
parent 13ef6b4739
commit fcab6ac583
5 changed files with 230 additions and 0 deletions
+6
@@ -541,6 +541,12 @@ For a working example of an orchestrator agent that uses sub-agent spawning, see
[sisyphus](https://github.com/Dark-Alex-17/coyote/blob/main/assets/agents/sisyphus) agent. For an example of the teammate messaging pattern with parallel sub-agents,
see the [code-reviewer](https://github.com/Dark-Alex-17/coyote/blob/main/assets/agents/code-reviewer) agent.
> **Jobs vs. sub-agents:** sub-agents are for work that needs *reasoning*: they run a full LLM loop. To background a
> single long-running **tool call** (a build, a test suite, a slow MCP invocation), use the
> [Background Jobs](Background-Jobs) system (`job__*` tools) instead. The two compose one way only: agents may start
> jobs, but a job can never start an agent or another job. Completion notifications
> (`system_notifications` entries on the next tool result) cover both systems.
## Spawning Configuration
| Setting | Type | Default | Description |
+216
@@ -0,0 +1,216 @@
# Background Jobs
Coyote can run long tool calls (e.g. builds, test suites, slow shell commands, slow MCP calls, etc.) as **background jobs**.
A background job is a single whitelisted tool call running as a detached task: the model starts it with `job__start`,
keeps working while it runs, gets a push notification when it finishes, and retrieves the result with `job__collect`.
Background jobs are available everywhere function calling is: plain REPL sessions, roles, agents, sub-agents, and
graph LLM nodes.
## Jobs vs. Sub-Agents
Jobs and [sub-agents](Agents#7-sub-agent-spawning-system) are complementary async systems:
| | Background job | Sub-agent |
|---|---|---|
| What runs | ONE tool call (a process or an MCP invocation) | A full agent with its own LLM loop |
| Thinks? | No. It only produces output | Yes. Plans, calls tools, can spawn its own agents/jobs |
| Use for | "This single command takes minutes" | "This sub-task needs reasoning" |
| Budget | `max_concurrent_jobs` (default 5) | `max_concurrent_agents` (default 4) |
The two systems compose **one way only**: agents (and graph LLM nodes) may start jobs, but a job can never start an
agent, another job, or invoke any built-in tool. A job task owns only a frozen snapshot of what it needs to run its
one tool call. There is no agent context inside a job for anything else to run against.
## The Five Tools
| Tool | Purpose |
|------|---------|
| `job__start` | Run a whitelisted tool call in the background. Returns a `job_<hex>` id immediately. |
| `job__check` | Non-blocking status probe: status, elapsed time, and a tail of output captured so far. Never consumes the result. |
| `job__collect` | Block until the job finishes, then return its result and remove the job. The single retrieval verb. Results over 50k chars are tail-capped by default; `full_result: true` skips the cap, `tail_lines: N` keeps only the last N lines. |
| `job__cancel` | Kill the job's process group (SIGTERM, then SIGKILL after a 5s grace) and discard the handle. Returns partial output. |
| `job__list` | List registered jobs with status, elapsed time, and bytes of output captured. |
`job__start` takes `{ tool, arguments }`, where `arguments` is the same object the tool takes when called directly.
It responds immediately:
```json
{
"status": "ok",
"job_id": "job_a1b2c3d4",
"tool": "execute_command",
"message": "Running in background. Check with job__check, block with job__collect, cancel with job__cancel. You will receive a system_notifications entry on completion. Jobs do not survive coyote exiting."
}
```
### Worked example: a long build
1. **Start:** The model backgrounds the slow command and keeps working:
```
job__start --tool execute_command --arguments {"command": "cargo build --release"}
→ { "status": "ok", "job_id": "job_a1b2c3d4", ... }
```
2. **Keep working:** The model edits files, runs other tools, or answers questions. An occasional
`job__check --id job_a1b2c3d4` shows live progress (`output_tail` is the last chunk of stdout+stderr captured
so far), without consuming anything.
3. **Notification arrives:** When the build finishes, the model's *next tool result* carries a
`system_notifications` entry (see [Push Notifications](#push-notifications) below) naming the exact collect
command.
4. **Collect:** `job__collect --id job_a1b2c3d4` returns the result (status, `result`, `exit_code`, elapsed time,
final output tail) and removes the job. Collecting a still-running job simply blocks until it finishes. This is
useful when the model has nothing else to do.
## What Can Be Backgrounded
Backgroundable tools:
* **External command tools:** `execute_command` and any custom Bash/Python/JavaScript tool
(see [Function Calling](Tools)), including agent-specific tools.
* **`mcp_invoke_*`:** MCP server tool invocations (see [MCP Servers](MCP-Servers)).
Everything else is rejected with a teaching error explaining what to do instead:
| Attempted tool | Error |
|---|---|
| `agent__*`, `job__*` | `'<tool>' is already asynchronous — call it directly. Agents may start jobs, but jobs never start agents or other jobs.` |
| `user__*` | `'<tool>' is interactive and must run in-turn — a background job cannot touch the terminal. Call it directly.` |
| `todo__*`, `memory__*`, `skill__*`, `rag__*` | `'<tool>' mutates agent/session state and must run in-turn. Call it directly.` |
| `fs_*`, `ast_grep` | `'<tool>' is fast — invoke it directly instead of backgrounding it.` |
| `mcp_search_*`, `mcp_describe_*`, `mcp_read_*`, `mcp_prompt_*` | `'<tool>' is a sub-second call; invoke it directly.` |
Each rejection ends with: `Backgroundable tools: external command tools (e.g. execute_command) and mcp_invoke_* calls.`
Two more gates always apply:
* **Context availability:** `job__start` can only background tools that were actually declared to the model in the
current request. A tool filtered out by a role/session/agent/graph-node `enabled_tools` list is rejected with
`'<tool>' is not enabled in this context — job__start can only background tools declared to you in this request.
Use the exact name of a tool from your current catalog.` Backgrounding is never a way around tool filters.
* **Capacity:** At the concurrency limit, `job__start` rejects with
`At capacity: N/M jobs running. Collect or cancel one first.`
And visibility follows capability: the `job__*` tools are only **declared** where they can do something. A context
whose declared tools include nothing backgroundable (e.g. a graph `llm` node with `tools: []`) sees no `job__*`
declarations at all. One carve-out: while a context still owns registered jobs (say a job was started and the tool
it used was then disabled mid-session), the lifecycle verbs (`job__check`/`collect`/`cancel`/`list`) stay declared
until the registry drains, only `job__start` disappears. A running job can never become unreachable.
Other errors you may see: `No job '<id>' is registered — it may have already been collected or cancelled. job__list
shows active jobs.` (unknown/consumed id), and cross-kind teaching errors — `'<id>' is a spawned agent, not a
background job — use agent__check / agent__collect / agent__cancel` (and the inverse from the `agent__*` tools).
## Push Notifications
When a job (or a spawned agent; completion notifications cover both) finishes, coyote merges a
`system_notifications` entry onto the **last tool result of the model's next tool batch**:
```json
{
"output": "...the tool's own result...",
"system_notifications": [
{
"event": "job_completed",
"id": "job_a1b2c3d4",
"tool_or_agent": "execute_command",
"status": "success",
"next_action": "job__collect --id job_a1b2c3d4 for output"
}
],
"notification_instruction": "Background tasks have finished; collect each result with its next_action command."
}
```
`event` is one of `job_completed`, `job_failed`, `agent_completed`, `agent_failed`. Notifications for jobs that were
already collected or cancelled are dropped. The model is never pointed at a dead id.
### The turn-end guardrail
If the model tries to end its turn with running or finished-but-uncollected background tasks, coyote injects a
system reminder instead of ending the turn:
```
[SYSTEM GUARDRAIL] You attempted to end your turn with 2 unreclaimed background task(s).
Still running (1):
- job_a1b2c3d4 (job): call `job__collect` (blocks until done, returns output) or `job__cancel` (discards)
...
Completed but UNCOLLECTED — collect NOW (1):
- `job__collect --id job_ffee0011`
```
After 3 reminders without action, coyote cancels the remaining tasks and discards any uncollected results, then
lets the turn end. In practice the model collects on the first reminder; the guardrail exists so results are never
silently abandoned.
## Configuration
```yaml
# config.yaml (global)
max_concurrent_jobs: 5 # default: 5; 0 disables background jobs entirely
```
```yaml
# agents/<name>/config.yaml (per-agent override)
max_concurrent_jobs: 2 # this agent gets its own budget; omit to inherit the global value
```
```yaml
# agents/<name>/graph.yaml (graph-agent override, agent-level — next to model/temperature)
max_concurrent_jobs: 2 # budget for the whole graph run; omit to inherit the global value
```
* Resolution is agent-override → global → default `5`, exactly like `max_tool_result_chars`.
* Also settable via the `COYOTE_MAX_CONCURRENT_JOBS` environment variable
(see [Environment Variables](Environment-Variables)).
* **`0` disables the feature** for that context: the `job__*` tools and their prompt instructions are simply never
offered to the model. It's as if the feature doesn't exist.
* **Function calling is required.** With a model or configuration that doesn't support function calling, jobs are
off for the same reason: no tool declarations ever reach the model.
* Every job budget is per-context: a sub-agent's jobs register with the sub-agent, not its parent, and each agent
resolves its own `max_concurrent_jobs`. Cancelling an agent also cancels its jobs.
* There is deliberately **no per-node budget** in graph agents: every `llm` node (including parallel branches in
the same super-step) draws from the graph run's one shared pool, so the budget belongs to the run as a whole.
(Job *ownership* is still node-local; see the fine print below.)
## Behavioral Fine Print
* **Snapshot semantics.** A job runs against a snapshot of the config, environment, and `PATH` taken at
`job__start`. Changes made afterwards don't affect a running job. A job only produces output; it never mutates
session state.
* **No persistence.** Jobs die with the coyote process. Quitting the REPL kills every job's process group; there is
no reattach-after-restart. Switching agents (`.agent`) also cancels running jobs. Background work belongs to the
context that started it.
* **Two output channels.** While running, stdout+stderr stream into a bounded ring buffer (last 64 KiB). That's the
`output_tail` that `job__check` shows, with `output_bytes_captured` counting everything ever written and
`tail_truncated` flagging a clipped tail. The *result* returned by `job__collect` is separate (the tool's actual
output), capped tail-first at the last 50,000 characters by default — build failures land at the tail, so that's
the end that's kept. The truncation header says exactly what was kept and what to do next time. Collecting is
consume-once, so decide **before** collecting (`job__check`'s `output_bytes_captured` shows the size):
pass `tail_lines: N` to keep only the last N lines, or `full_result: true` to skip the cap and return everything
(the session-wide `max_tool_result_chars` limit still applies). For very large outputs, prefer having the command
write to a file and paging it with `fs_read`.
* **Timeouts.** Process jobs honor `COYOTE_TOOL_TIMEOUT` (default 1800s, `0` = unlimited), resolved at
`job__start`; on expiry the process group is killed and collect reports the timeout as a tool error. MCP jobs have
no timeout; cancel a hung one with `job__cancel`.
* **Polling ergonomics.** `job__check` (and `job__list`) never trip coyote's tool-call loop detector, so a model can
legitimately poll. As a nudge against busy-waiting, once several consecutive `job__check` calls return an unchanged
status and output, the result gains a `hint` telling the model to stop polling and rely on the completion
notification instead; the counter resets the moment anything changes.
* **REPL surfaces.** When enabled, the `job__*` tools appear in `.info tools`, but they're built-in infrastructure.
`.list tools` and `.tool enable`/`.tool disable` deliberately exclude them, so they can't be individually toggled
(`.tool enable job__start` errors with "Unknown tool"). Availability is governed solely by `max_concurrent_jobs`
and function-calling support.
* **Graph LLM nodes.** Jobs are **node-local**: the [graph](Graph-Agents) `llm` node that starts a job must collect
or cancel it before the node ends. The turn-end guardrail enforces this on clean paths (an uncollected job burns
node iterations and can fail the node at its iteration limit), and anything still registered when the node exits,
on any path, including errors and timeouts, is cancelled with its result discarded. There is no handing a job
to a downstream node. Parallel branches only ever see (and are nagged about) their own jobs. A node whose
`tools:` whitelist declares nothing backgroundable sees no `job__*` tools at all.
* **Escalations still surface.** If a `job__collect` would block while child agents have pending escalations, it
returns early with the escalation summary instead of deadlocking, and tells the model to reply first and collect
again.
+2
@@ -65,6 +65,8 @@ model: anthropic:claude-sonnet-4-6 # default model for llm nodes
temperature: 0.0 # default sampling temperature
top_p: null # default sampling top-p
reasoning_effort: null # default reasoning effort for llm nodes (model-dependent; e.g. low, medium, high)
max_concurrent_jobs: null # optional; graph-wide [background job](Background-Jobs) budget shared by all llm nodes
# (jobs themselves are node-local); overrides the global setting, 0 disables
global_tools: # global tools available to nodes
- web_search_coyote.sh
mcp_servers: # MCP servers available to nodes
+1
@@ -45,6 +45,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
* [Skills](Skills): Modular knowledge or capability packs the LLM can load and unload mid-conversation. Multiple skills compose; instructions stack, tools and MCPs union.
* [Agents](Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools.
* [Graph Agents](Graph-Agents): Define an agent as a declarative, YAML-driven workflow. A directed graph of typed nodes (LLM calls, scripts, approvals, user input, RAG retrieval, sub-agent spawns).
* [Background Jobs](Background-Jobs): Run long tool calls (builds, test suites, slow MCP calls) in the background with the `job__*` tools while the model keeps working; completion arrives as a push notification.
* [Todo System](TODO-System): Built-in task tracking for improved agent reliability with smaller models.
* [Environment Variables](Environment-Variables): Override and customize your Coyote configuration at runtime with environment variables.
* [Client Configurations](Clients): Configuration instructions for various LLM providers.
+5
@@ -53,6 +53,11 @@
- [State & Templates](Graph-Agents#state-and-template-syntax)
- [Structured Output](Graph-Agents#structured-output-output_schema)
- [Limitations](Graph-Agents#limitations--gotchas)
- [Background Jobs](Background-Jobs)
- [Jobs vs. Sub-Agents](Background-Jobs#jobs-vs-sub-agents)
- [The Five Tools](Background-Jobs#the-five-tools)
- [Push Notifications](Background-Jobs#push-notifications)
- [Configuration](Background-Jobs#configuration)
## Knowledge & Automation
- [Memory](Memory)