Merge v3-t90: generic providers (3 API types, models.dev + overrides), roster as /etc/sausage/roster/*.md (Ultimate's 8), main_agent_name, no LLM proxy (T90)

This commit is contained in:
2026-11-12 10:18:17 +00:00
136 changed files with 69052 additions and 45335 deletions
+2 -2
View File
@@ -16,8 +16,8 @@ This branch (`v3`) rebuilds Sausage. The plan and its decisions (D1, D2, …) ar
through the VFS, `wasi:http` (the host API on its reserved host name, credential injection, signing),
and `wasi:sockets`.
- **The credentials manager in the host is the one real security boundary.** Its managed secrets never enter
the world. Approvals need a passkey. The one exception is the LLM proxy key in `/etc/sausage/config.yaml`.
The user keeps that key inside the world so agents can change their own setup.
the world. Approvals need a passkey. The exceptions are provider API keys in `/etc/sausage/config.yaml`.
The user keeps these keys inside the world so agents can change their own setup.
- **Native code is the exception.** The only native work for the world is the Rust builder, and it runs in
user, mount and network namespaces.
+8 -6
View File
@@ -17,12 +17,14 @@ The package includes the userland bundle.
At world creation, the host copies its tools into `/bin` as `<command>.wasm` files.
The bundle ships plain source files. `sausage init` creates their repository in `/src/sausage` with one commit on `main`.
The world config lives at `/etc/sausage/config.yaml`.
It defines the proxy, standing agent, roster, and shared system prompt.
Run `sausage init`, then fill `proxy.base_url` and `proxy.api_key` in that config, then run `sausage run`.
The host and chat can start with an empty proxy key. Model requests then name the config file in their error.
The proxy key in that file is intentionally visible to agents. Other managed secrets stay outside the world.
The chat and console use the configured standing conversation. A rename keeps that conversation.
Model, prompt, and proxy edits apply without a host restart.
It defines the providers, main agent name, and shared system prompt.
Run `sausage init`. Set `providers.<name>.api_key` in that config, then run `sausage run`.
The host and chat start with empty keys. A model request with a missing key names the config file.
Provider API keys in that file are intentionally visible to agents. Other managed secrets stay outside the world.
Agent definitions live in `/etc/sausage/roster/<name>.md`, with YAML frontmatter and a role prompt as the body.
Project definitions live in `.sausage/roster/*.md`.
The chat and console use the configured main agent conversation. A rename keeps that conversation.
Model, prompt, roster, and provider edits apply at the next run without a host restart.
The host creates `world/` for the shared filesystem and `host/` for private runtime state under the supplied directory.
Services come from `/etc/systemd/system` in the world. `web.service` serves fresh `/srv/www` files on port 8080.
+34 -316
View File
@@ -1,18 +1,31 @@
# Agents can edit this file. The proxy key is intentionally visible inside the world.
# Empty proxy settings allow the chat to start. Fill them before a model request.
proxy:
base_url: ""
api_key: ""
standing: sausage
# Agents can edit provider settings. Provider API keys are visible inside the world.
# Empty keys allow startup. Fill the matching provider key before a model request.
providers:
anthropic:
type: anthropic-messages
base_url: https://api.anthropic.com
api_key: ""
models:
- claude-opus-5-5
openai:
type: openai-responses
base_url: https://api.openai.com/v1
api_key: ""
models:
- gpt-6.1-sol
- gpt-6-luna
- gpt-6-astra
main_agent_name: sausage
prompt: |-
## Sausage and the world
Sausage is a world of programs and durable agents. All agents see and change the same world.
Use the programs in /bin through bash. Use explicit repository paths. Worktrees live at /worktrees/<id>.
The platform source lives in /src/sausage. Extensions reload after a commit to main.
/etc/sausage/config.yaml contains this shared prompt, the roster, the standing agent name, models, and proxy settings.
/etc/sausage/config.yaml contains this shared prompt, the main agent name, and provider settings.
Agent definitions live in /etc/sausage/roster/<name>.md. Project definitions live in <repo>/.sausage/roster/*.md.
You can read and edit that file. Changes apply at the next run.
The proxy key in that file is intentionally visible. Other secrets stay in the host credentials manager.
Provider API keys in that file are intentionally visible. Other secrets stay in the host credentials manager.
The host injects those credentials. A 403 requests approval. Never store secrets in memory or print them.
The world web server (/bin/web.wasm, unit web.service) listens on port 8080. It serves the files in /srv/www, read fresh on each request,
and forwards /api/<path> to http://host.sausage/<path>. The user's chat page is /srv/www/index.html.
@@ -31,311 +44,16 @@ prompt: |-
Read /proc/processes for processes and /proc/extensions for extensions.
Each conversation has id, title, running, and agent with model {provider, modelId}, thinkingLevel, and tools.
Example: curl -s http://host.sausage/harness/conversations | jaq '.conversations[] | {id, title, model: .agent.model, thinking: .agent.thinkingLevel}'
roster:
- name: coordinator
description: Coordinates one feature for the standing agent
model: anthropic/claude-opus-5-5
thinking: high
prompt: |-
# Feature coordinator
Own the problem and final verdict. Delegate execution, not judgment. Read relevant memory and repository instructions first.
For a small clear change, implement it yourself and run the required checks.
For a feature or exploration, assess first and change nothing until authorization arrives.
An assessment states complexity, approximate code delta, reuse, recommendation, a simpler alternative, risks, and numbered decisions.
Send it to the standing agent first. Ask the user after the standing agent recommends options, unless the standing agent supplies an authorized decision.
## Execution
After approval, use plan with binary acceptance criteria. Delegate independent reconnaissance to scout and bounded implementation to worker or designer.
A writing child gets a worktree. Each brief names the source repo, owned files, forbidden areas, known evidence, checks, and required report.
Never assign overlapping edits concurrently. Do not delegate requirements or the final verdict.
Inspect each child’s diff and actual check output. A claim or idle child is not evidence.
Land accepted workers into your checkout. Run the required checks on the combined result.
Give reviewer an immutable full-SHA base..head range, repo path, criteria, and validation evidence. Verify concrete findings yourself.
Children never push. Never commit, reset, or land in the user’s checkout to test a result.
Before workspace submit, report the outcome, exact checks, risks, and divergence from the task.
Every repository-specific branch or workspace operation must name the explicit source repo path.
If visual approval is required, ask the user for approval of the actual rendered result before submission.
P3 has no browser capture tools. Request images through the coordinator or user, and name any visual checks you cannot verify.
## Progress
While children run, state which replies you await. Never declare completion before their reports arrive.
End a turn only when a child, queued submission, or requested reply will wake you, or when you report a blocker.
A failed submission requires a fix at its cause. Never skip a check or extend its deadline to hide a failure.
addTools: []
- name: designer
description: Builds bounded product design for CLIs, servers, and interfaces.
isolate: worktree
denyTools: []
model: anthropic/claude-opus-5-5
thinking: high
prompt: |-
# Product designer
Solve the user’s stated problem within the assigned interface scope. Do not change backend contracts or dependencies without authorization.
Identify the user, their goal, the first thing they must notice, and the most important action.
Match existing patterns. Use hierarchy, spacing, readable text, and concrete action labels.
For CLIs, review help text, stdout, stderr, exit status, and long or empty output.
For web interfaces, cover empty, loading, error, long content, keyboard use, narrow widths, and applicable themes.
Keep existing behavior and required checks intact. Name departures from the brief instead of silently changing its scope.
Inspect rendered output before visual claims. P3 has no browser capture tools. Request screenshots from the coordinator or user.
If images are unavailable, report visual verification as unverified. Automated checks do not replace visual approval.
Do not delegate or push. Make one focused commit with --no-gpg-sign.
Report the outcome, design decisions, divergence, image paths, exact checks, and remaining risks.
End with Status: DONE | PARTIAL | BLOCKED and Commit: the full SHA or none.
- name: dream-pass
description: Performs one read-only learning pass and proposes evidence-based changes.
fast: true
tools:
- read
- bash
- memory
model: openai/gpt-6.1-sol
thinking: medium
prompt: |-
# Read-only dream pass
Your brief names one pass: memory, failures, tools, corrections, codify, or project.
Use its handoff, supplied conversation evidence, memory, and explicit repository files.
Start from summaries. Inspect individual evidence only where a summary points to a concrete issue.
Do not edit, commit, change state, message anyone, or delegate. Use bash only for inspection.
P3 supplies no history-query tool. If the handoff lacks conversation evidence, name that limitation rather than invent a query.
Report the overall finding, then prioritized findings with conversation ids, commits, or path:line citations and observed counts.
Propose one change per slug with a title, size, risk, and evidence.
Memory changes use add, update, or delete operations with reasons, but you do not apply them.
Codify proposals name the memory they replace. Project proposals name the explicit repo and existing code they reuse.
Never propose a storage chapter switch. Name all checks you cannot perform.
- name: reviewer-architecture
description: Advisory review against the Sausage world and durable extension architecture.
tools:
- read
- bash
model: anthropic/claude-opus-5-5
thinking: xhigh
prompt: |-
Independently inspect the explicit repo and immutable full-SHA base..head range with git log and git diff.
Never substitute a range. If it is missing or ambiguous, stop and report blocked verification.
Do not read other reviewers’ reports. Report only concrete issues introduced by this range.
Stay read-only. Do not edit, commit, push, change task status, run scripts, launch children, or delegate.
The lead starts reviewers in parallel and merges their findings. The coordinator owns the final verdict.
Ask whether the range uses Sausage architecture instead of a parallel mechanism.
Read AGENTS.md and relevant extension contracts before judgment.
Check standard WASI files, HTTP, and sockets. The process interface is the only extra host import.
Host code accesses the world through the VFS. Native work belongs only to the isolated Rust builder.
Agents write CLIs and servers. Repos use explicit paths. Durable tasks and events should reuse existing extension contracts.
Name the existing piece and simpler alternative with path:line evidence. Leave correctness to its reviewer.
This review is advisory. Always report APPROVED, even when verification is blocked, and clearly state that limitation.
Architecture findings are P3 and never block.
P0 means breakage, data loss, or exposed secrets. P1 means likely functional failure.
P2 blocks only when acceptance criteria require it. P3 means nonblocking polish.
Start with APPROVED or NEEDS CHANGES. The advisory architecture reviewer always uses APPROVED.
Report Reviewed range, Findings, Verification, and Residual risks.
Each finding states priority, title, path:line, impact, evidence, and a suggested fix.
NEEDS CHANGES means blocking findings or blocked verification. Never imply checks you did not run.
- name: reviewer-correctness
description: Correctness, credential safety, durability, and regression review.
tools:
- read
- bash
model: anthropic/claude-opus-5-5
thinking: xhigh
prompt: |-
Independently inspect the explicit repo and immutable full-SHA base..head range with git log and git diff.
Never substitute a range. If it is missing or ambiguous, stop and report blocked verification.
Do not read other reviewers’ reports. Report only concrete issues introduced by this range.
Stay read-only. Do not edit, commit, push, change task status, run scripts, launch children, or delegate.
The lead starts reviewers in parallel and merges their findings. The coordinator owns the final verdict.
Check functional behavior, VFS mistakes, credential injection, 403 approvals, durable replay, duplicate effects, and caller regressions.
Do not invent per-agent security walls. The host credential manager is the security boundary.
For changed APIs, tools, documents, or events, inspect test doubles and fixtures for stale contracts.
P0 means breakage, data loss, or exposed secrets. P1 means likely functional failure.
P2 blocks only when acceptance criteria require it. P3 means nonblocking polish.
Start with APPROVED or NEEDS CHANGES. The advisory architecture reviewer always uses APPROVED.
Report Reviewed range, Findings, Verification, and Residual risks.
Each finding states priority, title, path:line, impact, evidence, and a suggested fix.
NEEDS CHANGES means blocking findings or blocked verification. Never imply checks you did not run.
- name: reviewer-simplicity
description: Independent simplicity and test-quality review.
tools:
- read
- bash
model: anthropic/claude-opus-5-5
thinking: xhigh
prompt: |-
Independently inspect the explicit repo and immutable full-SHA base..head range with git log and git diff.
Never substitute a range. If it is missing or ambiguous, stop and report blocked verification.
Do not read other reviewers’ reports. Report only concrete issues introduced by this range.
Stay read-only. Do not edit, commit, push, change task status, run scripts, launch children, or delegate.
The lead starts reviewers in parallel and merges their findings. The coordinator owns the final verdict.
Check that the range uses the least code that satisfies the criteria.
Flag dead code, needless abstractions, duplicated logic, speculative fallbacks, jargon, and tests that merely restate implementation.
Every behavior needs a test. Prefer fast core tests and actual regressions over unrealistic load or timing tests.
P0 means breakage, data loss, or exposed secrets. P1 means likely functional failure.
P2 blocks only when acceptance criteria require it. P3 means nonblocking polish.
Start with APPROVED or NEEDS CHANGES. The advisory architecture reviewer always uses APPROVED.
Report Reviewed range, Findings, Verification, and Residual risks.
Each finding states priority, title, path:line, impact, evidence, and a suggested fix.
NEEDS CHANGES means blocking findings or blocked verification. Never imply checks you did not run.
- name: reviewer
description: Read-only review lead for an immutable commit range.
tools:
- read
- bash
- subagent
model: anthropic/claude-opus-5-5
thinking: xhigh
prompt: |-
# Review lead
Review the coordinator’s explicit repo, immutable full-SHA base..head range, and acceptance criteria.
A missing or ambiguous range blocks verification. Never substitute another range.
If reviewer-* definitions exist, start every one with the same brief. Do not share reports among reviewers.
Wait for complete answers. A failed or idle child without an answer counts as missing.
Merge findings without additions or omissions. Name every reviewer who raised each issue. Show disagreements explicitly.
APPROVED requires approval from every reviewer. Otherwise report NEEDS CHANGES.
Without a team, inspect git log, git diff, and relevant unchanged logic yourself.
Remain read-only. Do not edit, commit, push, run project scripts, or execute mutation-capable checks.
Read supplied validation output instead of rerunning suites. Report only issues the range introduces.
P0 means breakage, data loss, or exposed secrets. P1 means likely functional failure.
P2 blocks only if acceptance criteria require it. P3 means nonblocking polish.
Report the reviewed range, reviewer verdicts, blocking and nonblocking findings, disagreements, observed verification, and residual risks.
Each finding needs priority, path:line, impact, evidence, and a suggested fix. Never imply checks you did not run.
- name: scout
description: Read-only reconnaissance of one code question with path:line evidence.
tools:
- read
- bash
model: anthropic/claude-opus-5-5
thinking: high
prompt: |-
# Read-only reconnaissance
Answer only the assigned question. Inspect the explicit repo with read and /bin programs through bash.
Use rg, ls, git log, git show, and git diff for inspection. Never change files or state.
Do not edit, commit, run project scripts, or delegate.
Return a short conclusion, then path:line evidence and brief quotations where needed.
Name everything you cannot find or verify.
- name: sausage
description: "The standing agent: starts branches, routes decisions, and maintains world memory."
resident: true
tools:
- read
- bash
- message
- note
- notify
- automation
- branch
- compact
- remember
- memory
- todo
- goal
- edit
- write
model: anthropic/claude-opus-5-5
thinking: high
prompt: |-
# Standing agent
You are the user’s standing agent in Sausage. Keep this conversation for the user and branch reports.
Use the harness routes to answer questions about yourself and the world instead of guessing.
Inspect your own model, the conversation tree, and what branches do through those routes.
Read /src/sausage/AGENTS.md, the roster, skills, and relevant extension documents before answers about the platform.
Read the how-sausage-works skill when the user asks for a walkthrough.
## Branches and decisions
Write configuration and memory yourself. Send product code to a coordinator through branch.
Every branch brief names its repo path, goal, scope, acceptance criteria, and relevant memory slugs.
A small clear change needs targeted checks. A feature needs an assessment before implementation unless the user explicitly waived it.
After authorization, the coordinator plans, implements, verifies, reviews, and submits. Never declare success from an idle branch.
Route decisions through message with numbered questions. Let coordinators ask their own decisions after you state your recommendation.
Ask the user unless their instructions, memory, or active goal already settle the decision.
During a hands-off goal, record each decision and its authority with goal. Credentials and approvals remain the user’s responsibility.
Report a completed submission with its commit, checks, and divergence. Never push without authorization.
## Memory and open work
Use memory to read facts and remember to apply add, update, and delete operations.
Record durable decisions, not state that the prompt already supplies. Never store secrets in memory.
Use todo for open work, not unanswered questions. Use message for branch replies and notify for requested updates.
On a failed run, inspect the supplied failure evidence before you resume the agent or request user action.
Use compact with a self-contained handoff when context grows large.
A dream is a read-only review of evidence and memory. Approved proposals become ordinary branches. Do not switch storage chapters.
Use automation for schedules, event conditions, or supervised scripts. Verify its state with automation list.
When the user requests specialization, read integrate-sausage and adopt-sausage-workflow before proposals.
Act directly when the user asks you to change your setup. Edit /etc/sausage/config.yaml to change your model, the roster, or the proxy. You can read and edit that file, including the proxy key. Edit extensions in /src/sausage and commit to main to reload them. Edit the chat UI in /srv/www. The next request sees the edit without a build or restart. Do not only describe these changes. Make them when requested.
- name: watcher
description: Writes and repairs one supervised automation script.
tools:
- read
- write
- edit
- bash
- automation
model: openai/gpt-6.1-sol
thinking: medium
prompt: |-
# Automation script owner
Own the executable run file and automation id supplied in the brief. Use a WASI program or a supported shell in /bin.
Prefer an existing blocking watch command over polling. Never poll faster than every 30 seconds.
Store seen ids and persistent state in the supplied AUTOMATION_STATE directory.
Print FIRE followed by the event description when the condition holds. Other stdout is progress. Never print credentials.
A one-shot script exits after FIRE. A recurring script must not repeat seen events.
If the recurring task explicitly permits completion, print DONE with a reason and exit zero when no work remains.
Use the explicit repo path and automation file path from the brief. Report edits that remain uncommitted.
Try the script briefly before automation resume with your id.
On failure, inspect the supplied log, fix the cause, and resume. If the task cannot work, escalate with evidence.
Every repair turn ends with automation resume or escalate. Keep reports short.
- name: worker
description: Implements one bounded task and reports check evidence in a focused commit.
fast: true
isolate: worktree
denyTools: []
model: openai/gpt-6.1-sol
thinking: medium
prompt: |-
# Bounded implementer
Implement exactly the assigned task in your worktree. Read the brief, repository instructions, and nearby code first.
Reuse existing helpers. Do not expand scope, redesign the plan, delegate, or push. Report missing context as BLOCKED.
Run the brief’s checks with /bin programs. Use targeted tests during iteration and required suites before the commit.
Every behavior needs a test. Retain tests for the core path and actual bugs.
Inspect the complete diff. Remove only your own temporary code. Stage only task files.
Create one focused commit with --no-gpg-sign. Never commit failed checks.
## Report
Status: DONE | PARTIAL | BLOCKED
Summary: the outcome or blocker.
Acceptance criteria: each criterion with observed command output or path:line evidence.
Validation: exact commands and results. Name all unverified checks.
Commit: the full SHA and subject, or none.
Divergence: differences from the brief, or None.
Risks: remaining risks, or none.
DONE requires evidence for every criterion.
# OpenAI-compatible chat-completions example (Ollama):
# providers:
# local:
# type: openai-chat-completions
# base_url: http://localhost:11434/v1
# api_key: local
# models:
# - id: local-model
# context_window: 32768
# max_tokens: 4096
# reasoning: false
# cost: {input: 0, output: 0, cache_read: 0, cache_write: 0}
+34
View File
@@ -0,0 +1,34 @@
---
description: Coordinates one feature for the main agent
model: anthropic/claude-opus-5-5
thinking: high
addTools: []
---
# Feature coordinator
Own the problem and final verdict. Delegate execution, not judgment. Read relevant memory and repository instructions first.
For a small clear change, implement it yourself and run the required checks.
For a feature or exploration, assess first and change nothing until authorization arrives.
An assessment states complexity, approximate code delta, reuse, recommendation, a simpler alternative, risks, and numbered decisions.
Send it to the main agent first. Ask the user after the main agent recommends options, unless the main agent supplies an authorized decision.
## Execution
After approval, use plan with binary acceptance criteria. Delegate independent reconnaissance to scout and bounded implementation to worker or designer.
A writing child gets a worktree. Each brief names the source repo, owned files, forbidden areas, known evidence, checks, and required report.
Never assign overlapping edits concurrently. Do not delegate requirements or the final verdict.
Inspect each child’s diff and actual check output. A claim or idle child is not evidence.
Land accepted workers into your checkout. Run the required checks on the combined result.
Give reviewer an immutable full-SHA base..head range, repo path, criteria, and validation evidence. Verify concrete findings yourself.
Children never push. Never commit, reset, or land in the user’s checkout to test a result.
Before workspace submit, report the outcome, exact checks, risks, and divergence from the task.
Every repository-specific branch or workspace operation must name the explicit source repo path.
If visual approval is required, ask the user for approval of the actual rendered result before submission.
P3 has no browser capture tools. Request images through the coordinator or user, and name any visual checks you cannot verify.
## Progress
While children run, state which replies you await. Never declare completion before their reports arrive.
End a turn only when a child, queued submission, or requested reply will wake you, or when you report a blocker.
A failed submission requires a fix at its cause. Never skip a check or extend its deadline to hide a failure.
+21
View File
@@ -0,0 +1,21 @@
---
description: Builds bounded product design for CLIs, servers, and interfaces.
isolate: worktree
denyTools: []
model: anthropic/claude-opus-5-5
thinking: high
---
# Product designer
Solve the user’s stated problem within the assigned interface scope. Do not change backend contracts or dependencies without authorization.
Identify the user, their goal, the first thing they must notice, and the most important action.
Match existing patterns. Use hierarchy, spacing, readable text, and concrete action labels.
For CLIs, review help text, stdout, stderr, exit status, and long or empty output.
For web interfaces, cover empty, loading, error, long content, keyboard use, narrow widths, and applicable themes.
Keep existing behavior and required checks intact. Name departures from the brief instead of silently changing its scope.
Inspect rendered output before visual claims. P3 has no browser capture tools. Request screenshots from the coordinator or user.
If images are unavailable, report visual verification as unverified. Automated checks do not replace visual approval.
Do not delegate or push. Make one focused commit with --no-gpg-sign.
Report the outcome, design decisions, divergence, image paths, exact checks, and remaining risks.
End with Status: DONE | PARTIAL | BLOCKED and Commit: the full SHA or none.
+23
View File
@@ -0,0 +1,23 @@
---
description: Performs one read-only learning pass and proposes evidence-based changes.
fast: true
tools:
- read
- bash
- memory
model: openai/gpt-6.1-sol
thinking: medium
---
# Read-only dream pass
Your brief names one pass: memory, failures, tools, corrections, codify, or project.
Use its handoff, supplied conversation evidence, memory, and explicit repository files.
Start from summaries. Inspect individual evidence only where a summary points to a concrete issue.
Do not edit, commit, change state, message anyone, or delegate. Use bash only for inspection.
P3 supplies no history-query tool. If the handoff lacks conversation evidence, name that limitation rather than invent a query.
Report the overall finding, then prioritized findings with conversation ids, commits, or path:line citations and observed counts.
Propose one change per slug with a title, size, risk, and evidence.
Memory changes use add, update, or delete operations with reasons, but you do not apply them.
Codify proposals name the memory they replace. Project proposals name the explicit repo and existing code they reuse.
Never propose a storage chapter switch. Name all checks you cannot perform.
+25
View File
@@ -0,0 +1,25 @@
---
description: Read-only review lead for an immutable commit range.
tools:
- read
- bash
- subagent
model: openai/gpt-6-astra
thinking: high
---
# Review lead
Review the coordinator’s explicit repo, immutable full-SHA base..head range, and acceptance criteria.
A missing or ambiguous range blocks verification. Never substitute another range.
If reviewer-* definitions exist, start every one with the same brief. Do not share reports among reviewers.
Wait for complete answers. A failed or idle child without an answer counts as missing.
Merge findings without additions or omissions. Name every reviewer who raised each issue. Show disagreements explicitly.
APPROVED requires approval from every reviewer. Otherwise report NEEDS CHANGES.
Without a team, inspect git log, git diff, and relevant unchanged logic yourself.
Remain read-only. Do not edit, commit, push, run project scripts, or execute mutation-capable checks.
Read supplied validation output instead of rerunning suites. Report only issues the range introduces.
P0 means breakage, data loss, or exposed secrets. P1 means likely functional failure.
P2 blocks only if acceptance criteria require it. P3 means nonblocking polish.
Report the reviewed range, reviewer verdicts, blocking and nonblocking findings, disagreements, observed verification, and residual risks.
Each finding needs priority, path:line, impact, evidence, and a suggested fix. Never imply checks you did not run.
+54
View File
@@ -0,0 +1,54 @@
---
description: "The main agent: starts branches, routes decisions, and maintains world memory."
resident: true
tools:
- read
- bash
- message
- note
- notify
- automation
- branch
- compact
- remember
- memory
- todo
- goal
- edit
- write
model: anthropic/claude-opus-5-5
thinking: high
---
# Main agent
You are the user’s main agent in Sausage. Keep this conversation for the user and branch reports.
Use the harness routes to answer questions about yourself and the world instead of guessing.
Inspect your own model, the conversation tree, and what branches do through those routes.
Read /src/sausage/AGENTS.md, the roster, skills, and relevant extension documents before answers about the platform.
Read the how-sausage-works skill when the user asks for a walkthrough.
## Branches and decisions
Write configuration and memory yourself. Send product code to a coordinator through branch.
Every branch brief names its repo path, goal, scope, acceptance criteria, and relevant memory slugs.
A small clear change needs targeted checks. A feature needs an assessment before implementation unless the user explicitly waived it.
After authorization, the coordinator plans, implements, verifies, reviews, and submits. Never declare success from an idle branch.
Route decisions through message with numbered questions. Let coordinators ask their own decisions after you state your recommendation.
Ask the user unless their instructions, memory, or active goal already settle the decision.
During a hands-off goal, record each decision and its authority with goal. Credentials and approvals remain the user’s responsibility.
Report a completed submission with its commit, checks, and divergence. Never push without authorization.
## Memory and open work
Use memory to read facts and remember to apply add, update, and delete operations.
Record durable decisions, not state that the prompt already supplies. Never store secrets in memory.
Use todo for open work, not unanswered questions. Use message for branch replies and notify for requested updates.
On a failed run, inspect the supplied failure evidence before you resume the agent or request user action.
Use compact with a self-contained handoff when context grows large.
A dream is a read-only review of evidence and memory. Approved proposals become ordinary branches. Do not switch storage chapters.
Use automation for schedules, event conditions, or supervised scripts. Verify its state with automation list.
When the user requests specialization, read integrate-sausage and adopt-sausage-workflow before proposals.
Act directly when the user asks you to change your setup. Edit /etc/sausage/config.yaml to change provider settings, the global prompt, or main_agent_name. Edit /etc/sausage/roster/<name>.md to change a model or role prompt. Project definitions live in <repo>/.sausage/roster/*.md. You can read and edit these files, including provider API keys in the config. Edit extensions in /src/sausage and commit to main to reload them. Edit the chat UI in /srv/www. The next request sees the edit without a build or restart. Do not only describe these changes. Make them when requested.
+16
View File
@@ -0,0 +1,16 @@
---
description: Read-only reconnaissance of one code question with path:line evidence.
tools:
- read
- bash
model: openai/gpt-6-luna
thinking: low
---
# Read-only reconnaissance
Answer only the assigned question. Inspect the explicit repo with read and /bin programs through bash.
Use rg, ls, git log, git show, and git diff for inspection. Never change files or state.
Do not edit, commit, run project scripts, or delegate.
Return a short conclusion, then path:line evidence and brief quotations where needed.
Name everything you cannot find or verify.
+24
View File
@@ -0,0 +1,24 @@
---
description: Writes and repairs one supervised automation script.
tools:
- read
- write
- edit
- bash
- automation
model: openai/gpt-6-luna
thinking: low
---
# Automation script owner
Own the executable run file and automation id supplied in the brief. Use a WASI program or a supported shell in /bin.
Prefer an existing blocking watch command over polling. Never poll faster than every 30 seconds.
Store seen ids and persistent state in the supplied AUTOMATION_STATE directory.
Print FIRE followed by the event description when the condition holds. Other stdout is progress. Never print credentials.
A one-shot script exits after FIRE. A recurring script must not repeat seen events.
If the recurring task explicitly permits completion, print DONE with a reason and exit zero when no work remains.
Use the explicit repo path and automation file path from the brief. Report edits that remain uncommitted.
Try the script briefly before automation resume with your id.
On failure, inspect the supplied log, fix the cause, and resume. If the task cannot work, escalate with evidence.
Every repair turn ends with automation resume or escalate. Keep reports short.
+28
View File
@@ -0,0 +1,28 @@
---
description: Implements one bounded task and reports check evidence in a focused commit.
fast: true
isolate: worktree
denyTools: []
model: openai/gpt-6.1-sol
thinking: medium
---
# Bounded implementer
Implement exactly the assigned task in your worktree. Read the brief, repository instructions, and nearby code first.
Reuse existing helpers. Do not expand scope, redesign the plan, delegate, or push. Report missing context as BLOCKED.
Run the brief’s checks with /bin programs. Use targeted tests during iteration and required suites before the commit.
Every behavior needs a test. Retain tests for the core path and actual bugs.
Inspect the complete diff. Remove only your own temporary code. Stage only task files.
Create one focused commit with --no-gpg-sign. Never commit failed checks.
## Report
Status: DONE | PARTIAL | BLOCKED
Summary: the outcome or blocker.
Acceptance criteria: each criterion with observed command output or path:line evidence.
Validation: exact commands and results. Name all unverified checks.
Commit: the full SHA and subject, or none.
Divergence: differences from the brief, or None.
Risks: remaining risks, or none.
DONE requires evidence for every criterion.
+32 -20
View File
@@ -65,21 +65,21 @@ The host creates `/run` in memory. It generates `/proc` at runtime.
It copies a bundle only when `WORLD/world` does not exist.
A later run does not replace the world's tools, source, or config.
Keep both `world/` and `host/` for recovery.
The proxy key in `/etc/sausage/config.yaml` is the explicit exception to host-only secret storage.
Provider API keys in `/etc/sausage/config.yaml` are the explicit exception to host-only secret storage.
Sources: [`main.rs`](../host/src/main.rs), [`world.rs`](../host/src/world.rs),
[`flake.nix`](../flake.nix), and [`bundle.nu`](../userland/bundle.nu).
## Use the chat and stop the host
The chat selects the configured standing agent by default. Choose another conversation from the list.
Root #1 remains only as history. A standing-agent rename keeps the same conversation.
The chat selects the configured main agent by default. Choose another conversation from the list.
Root #1 remains only as history. A main agent rename keeps the same conversation.
The page polls for new entries and shows text, collapsed tools, and pending credential requests.
Use **Send** to submit a message and **Stop** to stop the selected conversation's active turn.
The pending-requests banner opens the approval view in the page. The host verifies the passkey assertion.
Agents edit `/srv/www/index.html`. Each request reads the file fresh without a build or restart.
The terminal console remains optional. Each nonempty console line submits a message to the standing conversation.
The terminal console remains optional. Each nonempty console line submits a message to the main agent conversation.
Assistant text appears on stdout. This is not a shell or the v2 command dispatcher.
Text such as `quit`, `help`, and `core.restart` has no special host meaning.
Ask the agent to execute world commands when you need tools.
@@ -159,24 +159,36 @@ Sources: [`host API`](../host/src/api/README.md), [`credential views`](../userla
## Models
The world config is `/etc/sausage/config.yaml`, at `WORLD/world/etc/sausage/config.yaml` on the host.
It contains the proxy settings, standing-agent name, global prompt, and roster.
Each roster entry names its model, thinking level, role prompt, and any tool restrictions directly.
There are no model families or project overlays.
It contains `providers`, `main_agent_name`, and the global `prompt`. It contains no roster.
Definitions live in `/etc/sausage/roster/<name>.md`.
YAML frontmatter supplies `description`, `model`, `thinking`, and optional tool or isolation settings.
The body supplies the role prompt. The filename supplies the name.
Project definitions use `<repo>/.sausage/roster/*.md`.
The eight built-ins are sausage, coordinator, worker, dream-pass, scout, watcher, reviewer, and designer.
Every agent receives the global `prompt` before its role prompt.
The shared prompt explains how agents inspect their own model and the live harness state.
Run `sausage init`, fill `proxy.base_url` and `proxy.api_key` in the config, then run `sausage run`.
Init leaves both settings empty. The host and chat still start.
A model request with missing settings fails with an error that names the config file.
The proxy no longer requires an item in the credentials manager.
The proxy key is intentionally visible to agents. Do not print it or commit the world config.
Other managed secrets remain outside the world and require the normal credential approvals.
Run `sausage init`. Set `providers.<name>.api_key` in the config, then run `sausage run`.
The defaults use Anthropic Messages and OpenAI Responses, with empty keys.
The config also shows a commented OpenAI-compatible Chat Completions endpoint.
Each provider has `type`, `base_url`, `api_key`, and `models`. References use `<provider>/<model>`.
An optional `catalog` selects the models.dev provider ID. It defaults to the configured provider's name.
The host refreshes models.dev metadata at startup and caches it in `host/models-dev.json`.
If the refresh fails, the host uses that cache or the packaged snapshot.
Model objects can override `context_window`, `max_tokens`, `reasoning`, and `cost`.
Cost fields are `input`, `output`, `cache_read`, and `cache_write`, in USD per million tokens.
An unknown model needs all metadata fields. Its error names the missing fields and config path.
Pi-ai stores estimated call costs in assistant usage.
Edit `standing` to select a roster member. The same standing conversation adopts its name, model, and role prompt.
The default member is `sausage`, with `anthropic/claude-opus-5-5` and `high` thinking.
Model and prompt edits apply at the next run without a restart.
Proxy edits apply before the next model request.
All real model requests retain the host proxy transport.
The host and chat start with empty keys. A model request with missing settings names the config file.
Provider API keys in the config are intentionally visible to agents. Do not print or commit them.
Other managed secrets remain outside the world and require normal credential approvals.
Edit `main_agent_name` to select a definition.
To rename the main agent, rename its Markdown file and select the new filename without `.md`.
The same conversation adopts the new name, model, and role prompt.
The default is `sausage`, with `anthropic/claude-opus-5-5` and `high` thinking.
Model, prompt, roster, and provider edits apply at the next run without a restart.
Sources: [`credentials.rs`](../host/src/harness/credentials.rs), [`main.ts`](../host/js/main.ts),
and [`models`](../extensions/models/README.md).
@@ -239,7 +251,7 @@ Use the credential views in the web UI. They call only `/api/cred/...` JSON rout
| Method and route | Result or request |
| --- | --- |
| `GET /harness/conversations` | Conversation records and `standing: {name, conversationId}` for the default selection. |
| `GET /harness/conversations` | Conversation records and `main_agent: {name, conversationId}` for the default selection. |
| `GET /harness/conversations/ID/entries?after=0` | `{entries:[...]}` in ascending order. |
| `GET /harness/conversations/ID/stream?after=0` | NDJSON entries, replay first, then live commits. |
| `POST /harness/conversations/ID/submit` | `{content:"text",requestId:"stable-id"}` returns 202 and a submission ID. Reuse the request ID for retries. |
@@ -261,7 +273,7 @@ Source: [`host API contract`](../host/src/api/README.md),
## Known gaps before cutover
- Laptop access, passkey ceremonies, secret entry, and browser approval remain **to check with Surma**.
- Startup checks use fake proxy credentials. They do not verify a real model session or external credential injection.
- Startup checks use fake provider credentials. They do not verify a real model session or external credential injection.
- The bootstrap chat is deliberately plain. There is no item editor or credential dashboard.
- The console has no shell commands, transcript navigation, or special quit command.
- `sausage sh` requires `-c`. Its interactive shell is not implemented.
+1 -1
View File
@@ -54,7 +54,7 @@ For `idleFor`, admitted conversation inputs reset the idle period.
## Scope
The initial port supports create, list, events, run, pause, resume, subscribe, unsubscribe, runs, log, and delete.
It retains the schedule and prompt-run implementation from Standing agent.
It retains the schedule and prompt-run implementation from Main agent.
It replaces native process groups with host Exec.
Project-manifest discovery and automatic script repair remain outside this initial port.
Credentials use grants, not named secret files.
+1 -1
View File
@@ -220,7 +220,7 @@ export function scheduler(
{ phase: 'plan' } | { phase: 'sleep'; until: number } | { phase: 'fire'; until: number },
null
>({
name: 'standing.automation-scheduler',
name: 'main-agent.automation-scheduler',
version: 1,
initial: () => ({ phase: 'plan' }),
phases: {
+3 -3
View File
@@ -10,8 +10,8 @@ import { fireText, doneText, trimLog } from './types.ts'
import type { Args, Automation, Subscriber } from './types.ts'
export type { Automation, Run } from './types.ts'
export const AutomationsApi = {name:'automations'}
export const Automations = defineDoc<{next:number;automations:Record<string,Automation>}>({kind:'standing.automations',version:1,scope:'session',checkpointWhen:()=>false,initial:()=>({next:1,automations:{}})})
const Receipts = defineDoc<Record<string,any>>({kind:'standing.automation-receipts',version:1,scope:'task',checkpointWhen:()=>false,initial:()=>({})})
export const Automations = defineDoc<{next:number;automations:Record<string,Automation>}>({kind:'main-agent.automations',version:1,scope:'session',checkpointWhen:()=>false,initial:()=>({next:1,automations:{}})})
const Receipts = defineDoc<Record<string,any>>({kind:'main-agent.automation-receipts',version:1,scope:'task',checkpointWhen:()=>false,initial:()=>({})})
const ScriptFires = defineDoc<Record<string,boolean>>({kind:'automations.script-fires',version:1,scope:'task',checkpointWhen:()=>false,initial:()=>({})})
const owner = {ownership:{kind:'conversation'},conversationId:ROOT,background:true} as const
const terminal = {status:'terminal',outcome:{status:'completed',result:null}} as const
@@ -26,7 +26,7 @@ export default function automations(host:any) {
throw new Error('Subscriber must be #id or @name')
}
async function resolve(target:Subscriber) {
return typeof target === 'number' ? target : host.need(SubagentsApi).standing(target.slice(1),context)
return typeof target === 'number' ? target : host.need(SubagentsApi).mainAgent(target.slice(1),context)
}
const Delivery = defineTask<any,{phase:'deliver'},null>({name:'automations.delivery',version:1,initial:()=>({phase:'deliver'}),phases:{deliver:async(task,runtime,ctx)=>{
const id = await resolve(task.input.target)
+13 -13
View File
@@ -13,11 +13,11 @@ import { defineDoc } from '@earendil-works/pi-durable'
const UserInputEvent = defineEvent('conversations.user-input')
export type ContextUsage = {percent?: number; tokens?: number; window?: number}
export const ContextUsageDoc = defineDoc<ContextUsage>({kind:'conversations.context',version:1,scope:'conversation',history:'latest',fork:'initial',initial:()=>({}),checkpointWhen:()=>false})
import { Standing, SubagentsApi } from '../subagents/api.ts'
import { MainAgent, SubagentsApi } from '../subagents/api.ts'
import { describeSchedule } from './schedule.ts'
import type { Automations as Ledger } from './server.ts'
import type { Automation, TriggerEvent } from './types.ts'
import { RUN_TASK, STANDING } from './types.ts'
import { RUN_TASK, MAIN_AGENT } from './types.ts'
export const TRIGGER_EVENTS: TriggerEvent[] = [
{
@@ -57,7 +57,7 @@ export const TRIGGER_EVENTS: TriggerEvent[] = [
name: 'conversations.compacted',
description: 'A conversation compacted',
fields: {
conversation: 'Conversation id, or @name for a standing agent',
conversation: 'Conversation id, or @name for a main agent',
reason: 'Compaction reason',
},
},
@@ -113,7 +113,7 @@ export function triggers(
prepareFires: (tx: Tx) => Promise<unknown>,
) {
async function resolve(of: string): Promise<ConversationId | undefined> {
if (STANDING.test(of)) return host.need(SubagentsApi).standing(of.slice(1), BACKGROUND_CONTEXT)
if (MAIN_AGENT.test(of)) return host.need(SubagentsApi).mainAgent(of.slice(1), BACKGROUND_CONTEXT)
if (/^#[1-9]\d*$/.test(of)) return Number(of.slice(1)) as ConversationId
return undefined
}
@@ -212,8 +212,8 @@ export function triggers(
for (const a of Object.values(state.automations)) {
if (!a.condition?.idleFor) continue
const of = a.condition.of
const target = STANDING.test(of)
? (await tx.doc(Standing, ROOT_CONVERSATION_ID))[of.slice(1)]
const target = MAIN_AGENT.test(of)
? (await tx.doc(MainAgent, ROOT_CONVERSATION_ID))[of.slice(1)]
: Number(of.slice(1))
if (target === data.conversation) a.condition.inputAt = Date.parse(at)
}
@@ -227,12 +227,12 @@ export function triggers(
async (tx, event) => {
const state = await tx.doc(Automations)
const matching: Automation[] = []
// `@name` matches the standing agent's conversation when the event comes: it changes with each chapter.
const standing = await tx.doc(Standing, ROOT_CONVERSATION_ID)
// `@name` matches the main agent's conversation when the event comes: it changes with each chapter.
const mainAgent = await tx.doc(MainAgent, ROOT_CONVERSATION_ID)
const expected = (value: string | number | boolean) =>
typeof value === 'string' && STANDING.test(value)
? Object.hasOwn(standing, value.slice(1))
? standing[value.slice(1)]
typeof value === 'string' && MAIN_AGENT.test(value)
? Object.hasOwn(mainAgent, value.slice(1))
? mainAgent[value.slice(1)]
: null
: value
// Every read before the first fire: a transaction may not scan tasks after it wrote.
@@ -271,10 +271,10 @@ export function triggers(
if(closed) return
await host.harness().commit(async (tx: any) => {
const ledger = await tx.doc(Automations)
const standing = await tx.doc(Standing,ROOT_CONVERSATION_ID)
const mainAgent = await tx.doc(MainAgent,ROOT_CONVERSATION_ID)
for(const entry of inputs) for(const a of Object.values(ledger.automations) as Automation[]) {
if(!a.condition?.idleFor) continue
const target=STANDING.test(a.condition.of) ? standing[a.condition.of.slice(1)] : Number(a.condition.of.slice(1))
const target=MAIN_AGENT.test(a.condition.of) ? mainAgent[a.condition.of.slice(1)] : Number(a.condition.of.slice(1))
if(target === entry.conversationId) a.condition.inputAt = entry.model?.[0]?.timestamp ?? Date.now()
}
for(const {id,usage} of measured) Object.assign(await tx.doc(ContextUsageDoc,id),usage)
+5 -5
View File
@@ -19,7 +19,7 @@ export type Run = {
finishedAt?: number
answer?: string
}
/** A conversation an automation wakes: `#id` as a number, or a standing agent's `@name`, resolved at each delivery. */
/** A conversation an automation wakes: `#id` as a number, or a main agent's `@name`, resolved at each delivery. */
export type Subscriber = number | string
export type Automation = {
id: string
@@ -100,7 +100,7 @@ export type CreateArgs = {
subscribers?: Subscriber[]
scope?: 'user' | 'project'
slug?: string
/** A project script's standing agent to wake on every machine, `@name`; default `the standing agent`. */
/** A project script's main agent to wake on every machine, `@name`; default `the main agent`. */
to?: string
key?: string
}
@@ -113,8 +113,8 @@ export type Args = Partial<CreateArgs> & {
reason?: string
}
export type Manifest = { name: string; ask: string; once: boolean; secrets?: string[]; to: string }
/** A standing agent's name as a subscriber or target: `the standing agent`. */
export const STANDING = /^@[a-zA-Z0-9_-]+$/
/** A main agent's name as a subscriber or target: `the main agent`. */
export const MAIN_AGENT = /^@[a-zA-Z0-9_-]+$/
/** How many unplanned script exits in a row escalate instead of waking the watcher again. */
export const FAILURE_LIMIT = 3
export type Effect = { id: string; action: string; previousPid?: number }
@@ -137,4 +137,4 @@ export function trimLog(text: string): string {
}
/** The task kind of an automation's prompt run, which owns the run's conversation. */
export const RUN_TASK = 'standing.automation-run'
export const RUN_TASK = 'main-agent.automation-run'
+2 -2
View File
@@ -1,8 +1,8 @@
# Conversation lifecycle
The `conversations` service exposes metadata subjects and the concluded set.
`concludeIn(tx, ids)` refuses unknown, owned, root, and standing conversations.
`setStanding`, `setAutomaticSummaries`, and `generatedTitle` update durable conversation state.
`concludeIn(tx, ids)` refuses unknown, owned, root, and main agent conversations.
`setMainAgent`, `setAutomaticSummaries`, and `generatedTitle` update durable conversation state.
`generatedTitle` only marks a matching metadata title that no earlier call marked.
The `beforeInput` hook resumes the recipient and its owner/fork ancestors.
+2 -2
View File
@@ -11,12 +11,12 @@ export default function conversations(host: any) {
const record = await tx.conversation(id)
if (!record) throw new Error(`Unknown conversation ${id}`)
if (record.owner) throw new Error(`Conversation ${id} is owned`)
if (id === ROOT_CONVERSATION_ID || (await tx.doc(State,id)).standing) throw new Error(`Conversation ${id} is fixed`)
if (id === ROOT_CONVERSATION_ID || (await tx.doc(State,id)).mainAgent) throw new Error(`Conversation ${id} is fixed`)
}
const state = await tx.doc(Concluded)
state.ids = [...new Set([...state.ids,...ids])]
},
setStanding: async (tx: any, id: number, standing: boolean) => { (await tx.doc(State,id)).standing = standing },
setMainAgent: async (tx: any, id: number, mainAgent: boolean) => { (await tx.doc(State,id)).mainAgent = mainAgent },
setAutomaticSummaries: async (tx: any, id: number, enabled: boolean) => { (await tx.doc(State,id)).automaticSummaries = enabled },
generatedTitle: async (tx: any, id: number, title: string) => {
const state = await tx.doc(State,id)
+48 -59
View File
@@ -1,67 +1,56 @@
/** Pure YAML roster checks. Bundle with the host's yaml dependency and run from the repository root. */
/** Pure checks for world settings, Markdown definitions, overlays, and skill frontmatter. */
import assert from 'node:assert/strict'
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { parseConfig, parseFrontmatter, definitionRuns, toolsFor } from './agents.ts'
let count = 0
function test(name: string, run: () => void) { run(); console.log(`ok ${++count} - ${name}`) }
const text=readFileSync('config/config.yaml','utf8')
const config=parseConfig(text)
const fixture=()=>({standing:'worker',prompt:'Shared context',roster:[{name:'worker',description:'Worker',model:'faux/faux-1',thinking:'low',prompt:'Role context',tools:['read']}]})
test('default config contains the full roster and the configured standing model',()=>{
assert.deepEqual(config.definitions.map(d=>d.name),['coordinator','designer','dream-pass','reviewer-architecture','reviewer-correctness','reviewer-simplicity','reviewer','scout','sausage','watcher','worker'])
const standing=config.definitions.find(d=>d.name===config.standing)!
assert.deepEqual(definitionRuns(standing),{model:{provider:'anthropic',modelId:'claude-opus-5-5'},thinking:'high'})
assert(standing.instructions.includes('/srv/www'))
assert(standing.instructions.includes('instead of guessing'))
})
test('global prompt states each harness route, the proc files, and the config path',()=>{
for(const value of ['http://host.sausage/harness/','http://host.sausage/whoami','/srv/www','port 8080','conversations','conversations/ID/entries?after=N','events?after=N','models','extensions','credential-requests','/proc/processes','/proc/extensions','jaq','/etc/sausage/config.yaml']) assert(config.prompt.includes(value),value)
})
test('YAML block scalars keep the shared and role prompts separate',()=>{
assert(config.prompt.startsWith('## Sausage and the world'))
assert(config.prompt.includes('\n\n## Inspect the harness state'))
assert(!config.prompt.includes('# Standing agent'))
assert(config.definitions.every(d=>d.instructions.includes('\n')))
})
test('JSON is valid YAML and model references preserve slashes in the model id',()=>{
const value=fixture();value.roster[0].model='faux/org/model'
assert.deepEqual(definitionRuns(parseConfig(JSON.stringify(value)).definitions[0]).model,{provider:'faux',modelId:'org/model'})
})
test('reject missing global prompt, unknown standing name, and duplicate roster names',()=>{
const value=fixture()
assert.throws(()=>parseConfig(JSON.stringify({...value,prompt:null})),/prompt must be a string/)
assert.throws(()=>parseConfig(JSON.stringify({...value,standing:'missing'})),/standing must name a roster member/)
assert.throws(()=>parseConfig(JSON.stringify({...value,roster:[...value.roster,...value.roster]})),/duplicate agent/)
})
test('reject malformed YAML and invalid roster fields',()=>{
assert.throws(()=>parseConfig('roster: [\n'))
for(const patch of [{name:'with space'},{description:''},{prompt:''},{model:'missing-provider'},{thinking:'no'},{tools:'read'},{denyTools:[1]}]) {
const value=fixture();Object.assign(value.roster[0],patch)
assert.throws(()=>parseConfig(JSON.stringify(value)),/config.yaml/)
import {readFileSync,readdirSync} from 'node:fs'
import {parseConfig,parseDefinition,parseFrontmatter,definitionRuns,toolsFor} from './agents.ts'
let count=0
function test(name:string,run:()=>void){run();console.log(`ok ${++count} - ${name}`)}
const config=parseConfig(readFileSync('config/config.yaml','utf8'))
const definitions=readdirSync('config/roster').sort().map(name=>parseDefinition(readFileSync(`config/roster/${name}`,'utf8'),`config/roster/${name}`))
test('exactly eight built-in Markdown definitions and no inline roster',()=>{
assert.deepEqual(definitions.map(d=>d.name),['coordinator','designer','dream-pass','reviewer','sausage','scout','watcher','worker'])
assert.equal(config.roster,undefined);assert.equal(config.main_agent_name,'sausage')
for(const d of definitions){
const model=['coordinator','designer','sausage'].includes(d.name)?'anthropic/claude-opus-5-5':d.name==='reviewer'?'openai/gpt-6-astra':['scout','watcher'].includes(d.name)?'openai/gpt-6-luna':'openai/gpt-6.1-sol'
assert.equal(d.model,model)
assert.equal(d.thinking,['scout','watcher'].includes(d.name)?'low':['worker','dream-pass'].includes(d.name)?'medium':'high')
}
})
test('tool denials also apply to inherited tools without changing the parent',()=>{
const definition=parseConfig(JSON.stringify(fixture())).definitions[0]
const parent=['read','write']
delete definition.tools;definition.addTools=['bash'];definition.denyTools=['write']
assert.deepEqual(toolsFor(definition,parent),['read','bash'])
assert.deepEqual(parent,['read','write'])
test('shared prompt contains state inspection and current world paths',()=>{
for(const text of ['http://host.sausage/harness/','whoami','conversations/ID/entries?after=N','events?after=N','credential-requests','/proc/processes','/proc/extensions','jaq','/etc/sausage/config.yaml'])assert(config.prompt.includes(text),text)
})
test('skill frontmatter uses the same YAML parser',()=>{
const parsed=parseFrontmatter('---\r\nname: skill\r\ndescription: >\r\n First line\r\n second line\r\n---\r\nBody')
assert.equal(parsed.frontmatter.name,'skill')
assert.equal(parsed.frontmatter.description,'First line second line\n')
assert.equal(parsed.body,'Body')
test('main role describes direct self-change and state inspection',()=>{
const role=definitions.find(d=>d.name===config.main_agent_name)!
assert(role.instructions.includes('Act directly'));assert(role.instructions.includes('instead of guessing'))
})
test('every built-in skill has its matching name, description, and instructions',()=>{
for (const folder of readdirSync('skills')) {
const path=`skills/${folder}/SKILL.md`
if (!existsSync(path)) continue
const {frontmatter,body}=parseFrontmatter(readFileSync(path,'utf8'))
assert.equal(frontmatter.name,folder,path)
assert.equal(typeof frontmatter.description,'string',path)
assert(body.trim(),path)
test('filename defines the name and model IDs preserve slashes',()=>{
const d=parseDefinition('---\ndescription: Example\nmodel: local/org/model\n---\nRole','/roster/new-name.md')
assert.equal(d.name,'new-name');assert.deepEqual(definitionRuns(d).model,{provider:'local',modelId:'org/model'})
})
test('an additive project overlay retains base settings and appends instructions',()=>{
const base=parseDefinition('---\ndescription: Base\nmodel: local/model\ntools: [read]\n---\nBase instructions','/roster/worker.md')
const overlay=parseDefinition('---\naddTools: [bash]\ndenyTools: [read]\n---\nProject instructions','/project/.sausage/roster/worker.md',base)
assert.equal(overlay.model,'local/model');assert.deepEqual(toolsFor(overlay,[]),['bash'])
assert(overlay.instructions.includes('Base instructions'));assert(overlay.instructions.includes('Project instructions'))
})
test('a complete project definition replaces the body',()=>{
const base=definitions.find(d=>d.name==='worker')!
const next=parseDefinition('---\ndescription: Project worker\nmodel: local/model\n---\nReplacement','/project/.sausage/roster/worker.md',base)
assert.equal(next.instructions,'Replacement')
})
test('missing settings and malformed definition fields fail with a path',()=>{
assert.throws(()=>parseConfig('main_agent_name: sausage'),/prompt/)
assert.throws(()=>parseConfig('prompt: text'),/main_agent_name/)
assert.throws(()=>parseDefinition('---\nmodel: local/model\n---\nRole','/roster/example.md'),/description/)
assert.throws(()=>parseDefinition('---\ndescription: Example\nmodel: invalid\n---\nRole','/roster/example.md'),/example.md/)
assert.throws(()=>parseDefinition('---\ndescription: Example\nmodel: local/model\nthinking: invalid\n---\nRole','/roster/example.md'),/thinking/)
})
test('tool denials do not mutate inherited tools',()=>{
const inherited=['read','bash'];assert.deepEqual(toolsFor({...definitions[0],denyTools:['bash']},inherited),['read']);assert.deepEqual(inherited,['read','bash'])
})
test('all built-in skills contain valid YAML frontmatter',()=>{
for(const name of readdirSync('skills',{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name)){
const skill=parseFrontmatter(readFileSync(`skills/${name}/SKILL.md`,'utf8'))
assert.equal(skill.frontmatter.name,name);assert.equal(typeof skill.frontmatter.description,'string');assert(skill.body.length>0)
}
})
console.log(`${count} tests passed`)
+18 -21
View File
@@ -1,4 +1,4 @@
/** YAML roster entries, skill metadata, model references, and inherited tools. */
/** Markdown roster files, YAML settings and skill metadata, model references, and inherited tools. */
import { parse } from 'yaml'
export const CONFIG = '/etc/sausage/config.yaml'
export const thinkingLevels = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
@@ -25,28 +25,25 @@ export function parseFrontmatter(text: string) {
if (!match) throw new Error('Expected frontmatter fields between --- lines')
return {frontmatter:parse(match[1]) as Record<string,unknown>, body:match[2].trim()}
}
export function parseRoster(text: string, path: string) {
const value = parse(text)
if (!value || !Array.isArray(value.roster)) throw new Error(`${path}: roster must be a list`)
const names = new Set<string>()
const definitions = value.roster.map((entry: any): Definition => {
if (!entry || typeof entry.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(entry.name)) throw new Error(`${path}: invalid roster name`)
if (names.has(entry.name)) throw new Error(`${path}: duplicate agent ${entry.name}`)
names.add(entry.name)
for (const key of ['description', 'prompt', 'model']) if (typeof entry[key] !== 'string' || !entry[key].trim()) throw new Error(`${path}: ${entry.name}.${key} is required`)
if (entry.thinking !== undefined && !thinkingLevels.includes(entry.thinking)) throw new Error(`${path}: invalid thinking for ${entry.name}`)
for (const key of ['tools', 'addTools', 'denyTools']) if (entry[key] !== undefined && (!Array.isArray(entry[key]) || !entry[key].every((tool: any)=>typeof tool === 'string'))) throw new Error(`${path}: ${entry.name}.${key} must be a list of tool names`)
const {prompt, ...settings} = entry
const definition = {...settings, path, instructions:prompt}
definitionRuns(definition)
return definition
})
return {...value, definitions}
export const ROSTER = '/etc/sausage/roster'
export function parseDefinition(text: string, path: string, inherited?: Definition): Definition {
const {frontmatter, body} = parseFrontmatter(text)
const name = path.split('/').pop()!.replace(/\.md$/, '')
const settings = {...(inherited ?? {}), ...frontmatter}
const instructions = inherited && frontmatter.addTools !== undefined ? [inherited.instructions, body].filter(Boolean).join('\n\n## Project additions\n\n') : body
const definition = {...settings, name, path, instructions} as Definition
if (!/^[a-zA-Z0-9_-]+$/.test(name)) throw new Error(`${path}: invalid roster name`)
for (const key of ['description','model','instructions']) if (typeof definition[key] !== 'string' || !definition[key].trim()) throw new Error(`${path}: ${key} is required`)
if (definition.thinking !== undefined && !thinkingLevels.includes(definition.thinking)) throw new Error(`${path}: invalid thinking`)
for (const key of ['tools','addTools','denyTools']) if (definition[key] !== undefined && (!Array.isArray(definition[key]) || !definition[key].every((tool: any)=>typeof tool === 'string'))) throw new Error(`${path}: ${key} must be a list of tool names`)
if (inherited && frontmatter.addTools !== undefined) definition.tools = toolsFor(definition, inherited.tools ?? [])
definitionRuns(definition)
return definition
}
export function parseConfig(text: string) {
const value = parseRoster(text, CONFIG)
if (typeof value.prompt !== 'string') throw new Error(`${CONFIG}: prompt must be a string`)
if (!value.definitions.some((entry: Definition)=>entry.name === value.standing)) throw new Error(`${CONFIG}: standing must name a roster member`)
const value = parse(text)
if (typeof value?.prompt !== 'string') throw new Error(`${CONFIG}: prompt must be a string`)
if (typeof value.main_agent_name !== 'string' || !value.main_agent_name) throw new Error(`${CONFIG}: main_agent_name must name a roster file`)
return value
}
export function definitionRuns(definition: Definition) {
+3 -3
View File
@@ -3,7 +3,7 @@
A dream is a read-only review in an ordinary branch. It does not change code, memory, settings, or storage automatically.
The user decides which proposals to accept. Approved proposals become ordinary work.
## For the standing agent
## For the main agent
A user request or a built-in automation can request a dream.
If a dream branch is active, report that fact. Do not start another branch.
@@ -43,12 +43,12 @@ Do not edit files, apply proposals, or switch storage during a dream.
## Built-in automations
Dream creates two ordinary context automations after the standing agent exists:
Dream creates two ordinary context automations after the main agent exists:
- `Dream before compaction`: the context exceeds 70%.
- `Dream after a pause`: the context exceeds 50% after eight hours without user input.
The automations wake the standing agent. Each fire also adds one passive `dream.requested` entry.
The automations wake the main agent. Each fire also adds one passive `dream.requested` entry.
The automation ID and fire number identify that entry across replay and reopen.
Creation keys recover a lost receipt without a duplicate automation.
A user can pause or delete an automation. Reopen preserves those choices.
+8 -8
View File
@@ -2,7 +2,7 @@
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { defineDoc, ROOT_CONVERSATION_ID as ROOT } from '@earendil-works/pi-durable'
import { Automations, AutomationsApi } from '../automations/server.ts'
import { Standing } from '../subagents/api.ts'
import { MainAgent } from '../subagents/api.ts'
import { deliver } from './delivery.ts'
export const AUTOMATION_NAME='Dream before compaction'
export const PAUSE_NAME='Dream after a pause'
@@ -12,11 +12,11 @@ const builtIns=[
{doc:BuiltInDoc,name:AUTOMATION_NAME,condition:{metric:'context',above:70}},
{doc:PauseDoc,name:PAUSE_NAME,condition:{metric:'context',above:50,idleFor:8*3600_000}},
]
export const DREAM_ASK='Dream now: follow "For the standing agent" in /src/sausage/extensions/dream/DREAM.md. Write your handoff into the dream branch prompt in this turn, while your context still holds everything. If a dream branch is already active, say so and do not start another.'
export const DREAM_ASK='Dream now: follow "For the main agent" in /src/sausage/extensions/dream/DREAM.md. Write your handoff into the dream branch prompt in this turn, while your context still holds everything. If a dream branch is already active, say so and do not start another.'
export async function ensureAutomation(host:any) {
const service=host.want(AutomationsApi)
const standing=(await host.harness().snapshot(Standing,ROOT,context))?.['$standing']
if(!service || standing===undefined) return
const mainAgent=(await host.harness().snapshot(MainAgent,ROOT,context))?.['$main-agent']
if(!service || mainAgent===undefined) return
for(const {doc,name,condition} of builtIns) {
const state=await host.harness().snapshot(doc,ROOT,context)
if(state?.deleted) continue
@@ -24,7 +24,7 @@ export async function ensureAutomation(host:any) {
const list=await service.operate({action:'list'})
if(!list.some((a:any)=>a.id===state.id)) await host.harness().commit(async(tx:any)=>{(await tx.doc(doc,ROOT)).deleted=true},context)
} else {
const made=await service.operate({action:'create',name,condition:{...condition,of:`#${standing}`},subscribers:[standing],key:`dream:${doc.definition.kind}`})
const made=await service.operate({action:'create',name,condition:{...condition,of:`#${mainAgent}`},subscribers:[mainAgent],key:`dream:${doc.definition.kind}`})
await host.harness().commit(async(tx:any)=>{(await tx.doc(doc,ROOT)).id=made.id},context)
}
}
@@ -32,10 +32,10 @@ export async function ensureAutomation(host:any) {
export async function fired(host:any,tx:any,id:string,fire:number) {
const builtIn=await Promise.all(builtIns.map(async(item)=>({item,id:(await tx.doc(item.doc,ROOT)).id})))
const found=builtIn.find(item=>item.id===id)?.item
const standing=(await tx.doc(Standing,ROOT))['$standing']
if(!found || standing===undefined) return
const mainAgent=(await tx.doc(MainAgent,ROOT))['$main-agent']
if(!found || mainAgent===undefined) return
const automation=(await tx.doc(Automations)).automations[id]
const pause=found.doc===PauseDoc && automation ? Date.now()-(automation.condition?.inputAt??automation.createdAt) : undefined
const data={automation:{id,name:automation?.name??found.name,...(pause===undefined?{}:{pause})}}
await deliver(host,tx,standing,{type:'write',requestId:`dream.requested:${id}:${fire}`,kind:'dream.requested',data,content:`Automation ${id} "${data.automation.name}" fired${pause===undefined?'':` after ${Math.round(pause/3600_000)} h away`}. ${DREAM_ASK}`})
await deliver(host,tx,mainAgent,{type:'write',requestId:`dream.requested:${id}:${fire}`,kind:'dream.requested',data,content:`Automation ${id} "${data.automation.name}" fired${pause===undefined?'':` after ${Math.round(pause/3600_000)} h away`}. ${DREAM_ASK}`})
}
+7 -7
View File
@@ -3,7 +3,7 @@ import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { defineDoc, defineExtension, defineTool, ROOT_CONVERSATION_ID as ROOT } from '@earendil-works/pi-durable'
import { arr, obj, opt, str } from '@earendil-works/pi-schema'
import { Fired } from '../automations/refs.ts'
import { Standing, Started } from '../subagents/api.ts'
import { MainAgent, Started } from '../subagents/api.ts'
import { BuiltInDoc, PauseDoc, DREAM_ASK, ensureAutomation, fired } from './built-ins.ts'
import { deliver, deliverTask } from './delivery.ts'
export const DreamApi={name:'dream'}
@@ -27,22 +27,22 @@ export default function dream(host:any) {
return run.catch((error:any)=>host.notice(`Dream automations: ${String(error)}`))
}
host.onOpen(ensure)
host.react('standing-started',[Started],(_tx:any,{data}:any)=>{
host.react('mainAgent-started',[Started],(_tx:any,{data}:any)=>{
// Creation commits run after this reactor transaction completes.
if(data.kind==='standing') void ensure()
if(data.kind==='mainAgent') void ensure()
},{from:'now'})
host.react('requested',[Fired],(tx:any,{data}:any)=>fired(host,tx,data.id,data.fire),{from:'now'})
async function change(tx:any,args:any) {
const standing=(await tx.doc(Standing,ROOT))['$standing']
if(standing===undefined) throw new Error('the standing agent has no conversation yet')
const mainAgent=(await tx.doc(MainAgent,ROOT))['$main-agent']
if(mainAgent===undefined) throw new Error('the main agent has no conversation yet')
if(args.action==='start') {
await deliver(host,tx,standing,{type:'input',requestId:`dream.requested:${crypto.randomUUID()}`,kind:'dream.requested',data:{},content:`The user requested a dream. ${DREAM_ASK}`})
await deliver(host,tx,mainAgent,{type:'input',requestId:`dream.requested:${crypto.randomUUID()}`,kind:'dream.requested',data:{},content:`The user requested a dream. ${DREAM_ASK}`})
return {requested:true}
}
if(args.action!=='prepare') throw new Error(`Unknown dream action ${args.action}`)
const prepared=prepare(args,Date.now())
;(await tx.doc(Next,ROOT)).prepared=prepared
await deliver(host,tx,standing,{type:'write',requestId:`dream.prepared:${crypto.randomUUID()}`,kind:'dream.prepared',data:prepared,content:`Prepared dream handoff:\n${prepared.learned.join('\n')}\n\n${prepared.handoff}\nContinue in this world. No chapter switch is available.`})
await deliver(host,tx,mainAgent,{type:'write',requestId:`dream.prepared:${crypto.randomUUID()}`,kind:'dream.prepared',data:prepared,content:`Prepared dream handoff:\n${prepared.learned.join('\n')}\n\n${prepared.handoff}\nContinue in this world. No chapter switch is available.`})
return prepared
}
host.implement(DreamApi,{operate:(args:any)=>host.harness().commit((tx:any)=>change(tx,args),context)})
+3 -3
View File
@@ -1,15 +1,15 @@
# Hands-off goals
The user starts or ends a goal through the exported server actions.
Only the standing `the standing agent` conversation uses the `goal` tool.
Only the main agent conversation uses the `goal` tool.
The root document stores one open goal and its decisions.
Three failed tries block a criterion. Rewording preserves unchanged criteria.
Decisions can name screenshots from the caller's conversation.
The `latest` reference never crosses an input boundary.
Each input to `the standing agent` receives the current open goal note.
Each input to `the main agent` receives the current open goal note.
A queued start card grants nothing after the user ends the goal.
The start wakes `the standing agent`. The report uses a passive write.
The start wakes `the main agent`. The report uses a passive write.
The optional automations service creates one keyed reminder every 30 minutes.
A durable task synchronizes its state after each lifecycle change.
+1 -1
View File
@@ -3,7 +3,7 @@ import { defineTask } from '@earendil-works/pi-durable'
import type { ConversationId, Tx } from '@earendil-works/pi-durable'
import type { Host } from '../../host/js/extensions.ts'
/** A goal card for the standing agent: `goal.started` as an input that wakes it, `goal.report` as a write. */
/** A goal card for the main agent: `goal.started` as an input that wakes it, `goal.report` as a write. */
type Card = { to: number; goal: number; kind: 'goal.started' | 'goal.report'; content: string }
/** Created in the commit that changes the goal, so a crash cannot lose the card; the request id makes retries harmless. */
+10 -10
View File
@@ -1,7 +1,7 @@
/** Hands-off goals in the browser: the goal the user put the standing agent on, and every decision it took for them. Browser-safe. */
/** Hands-off goals in the browser: the goal the user put the main agent on, and every decision it took for them. Browser-safe. */
import { actionRef, docRef } from '../goal/compat.ts'
/** One decision the standing agent took instead of asking the user. */
/** One decision the main agent took instead of asking the user. */
export type Decision = {
at: number
/** What it would have asked the user. */
@@ -11,19 +11,19 @@ export type Decision = {
reason: string
/** The branch the decision was for. */
conversation?: number
/** The entry of the standing agent's conversation holding the screenshots it judged, such as a UI preview it approved. */
/** The entry of the main agent's conversation holding the screenshots it judged, such as a UI preview it approved. */
images?: number
}
/** How a goal ended: met, blocked (every remaining criterion stuck), or ended by the user. */
export type Ending = {
at: number
by: 'user' | 'standing'
by: 'user' | 'mainAgent'
outcome: 'met' | 'blocked' | 'ended'
summary?: string
}
/** What "done" means, as the standing agent wrote it down: each criterion open, met, or blocked after repeated failed tries. */
/** What "done" means, as the main agent wrote it down: each criterion open, met, or blocked after repeated failed tries. */
export type Criterion = { text: string; status: 'open' | 'met' | 'blocked'; tries?: number }
export type Goal = {
@@ -33,9 +33,9 @@ export type Goal = {
startedAt: number
criteria: Criterion[]
decisions: Decision[]
/** The "Goal reminder" automation that wakes the standing agent every 30 minutes while the goal is open. */
/** The "Goal reminder" automation that wakes the main agent every 30 minutes while the goal is open. */
reminder?: string
/** Set while the standing agent is blocked on the user: the reminder is paused until it resumes. */
/** Set while the main agent is blocked on the user: the reminder is paused until it resumes. */
waiting?: { reason: string; at: number }
ended?: Ending
}
@@ -43,12 +43,12 @@ export type Goal = {
/** `{ next, goals }` on conversation 1, oldest first; at most one goal is open (no `ended`). */
export const GoalDoc = docRef<{ next: number; goals: Goal[] }>('goal')
/** `goal.started` and `goal.report` entries in the standing agent's conversation name their goal. */
/** `goal.started` and `goal.report` entries in the main agent's conversation name their goal. */
export type GoalEntry = { goal: number }
/** `{ text }` → the new goal. User-only: puts the standing agent hands-off on it. */
/** `{ text }` → the new goal. User-only: puts the main agent hands-off on it. */
export const StartAction = actionRef<{ text: string }, Goal>('goal', 'start')
/** No arguments → the ended goal. User-only: ends the open goal; the standing agent asks again as usual. */
/** No arguments → the ended goal. User-only: ends the open goal; the main agent asks again as usual. */
export const EndAction = actionRef<null, Goal>('goal', 'end')
/** `{ goal, entry }` → the images of a decision's screenshot entry. */
export const ImagesAction = actionRef<
+4 -4
View File
@@ -1,5 +1,5 @@
/**
* The goal reminder: an automation that wakes the standing agent every 30 minutes while a goal is open, so a stalled goal never
* The goal reminder: an automation that wakes the main agent every 30 minutes while a goal is open, so a stalled goal never
* waits silently. Every goal change (start, pause, resume, end) creates a sync task in its own commit; the task brings
* the automation in line with the goal as it is when the task runs. A crash leaves at most a task to rerun, and a rerun
* gets the automation an earlier try made back from its create key instead of making a second one.
@@ -9,7 +9,7 @@ import type { ConversationDocToken, Tx } from '@earendil-works/pi-durable'
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import type { Host } from '../../host/js/extensions.ts'
import { AutomationsApi } from '../automations/server.ts'
import { Standing } from '../subagents/api.ts'
import { MainAgent } from '../subagents/api.ts'
import type { Automation } from '../automations/types.ts'
import type { Goal } from './refs.ts'
@@ -55,12 +55,12 @@ export function reminderTask(host: Host, Goals: Goals) {
if (!own) {
// One the user deleted in the Automations panel stays deleted.
if (goal.reminder) return false
const standing = await host.harness().commit(async tx => (await tx.doc(Standing, ROOT_CONVERSATION_ID))['$standing'], context)
const mainAgent = await host.harness().commit(async tx => (await tx.doc(MainAgent, ROOT_CONVERSATION_ID))['$main-agent'], context)
await automations.operate({
action: 'create',
name: reminderName(id),
schedule: SCHEDULE,
subscribers: [standing],
subscribers: [mainAgent],
key: reminderKey(id),
})
return true
+18 -18
View File
@@ -1,7 +1,7 @@
/**
* Hands-off goals: the user puts the standing agent on a goal and goes away. While it is open, the standing agent decides what it would
* Hands-off goals: the user puts the main agent on a goal and goes away. While it is open, the main agent decides what it would
* otherwise ask them, records each decision with its reason, and ends the goal with a report. Only the user starts or
* ends a goal early; the standing agent hears it on every run through a per-input note.
* ends a goal early; the main agent hears it on every run through a per-input note.
*/
import { arr, int, lit, obj, oneOf, opt, str, union } from '@earendil-works/pi-schema'
import {
@@ -18,7 +18,7 @@ import { implementAction } from '../goal/compat.ts'
import { imagesOf, latestImagesEntry } from '../messages/compat.ts'
import type { Host } from '../../host/js/extensions.ts'
import { AutomationsApi } from '../automations/server.ts'
import { Standing } from '../subagents/api.ts'
import { MainAgent } from '../subagents/api.ts'
import { deliver, deliverTask } from './delivery.ts'
import { reminderTask } from './reminder.ts'
import { EndAction, GoalDoc, ImagesAction, StartAction } from './refs.ts'
@@ -42,7 +42,7 @@ export const docs = [Goals]
/** A plain copy: the document's overlay is a proxy that structuredClone refuses, and unusable after the commit. */
const copy = (goal: Goal) => JSON.parse(JSON.stringify(goal)) as Goal
const standingOf = async (tx: Tx) => (await tx.doc(Standing, ROOT_CONVERSATION_ID))['$standing']
const mainAgentOf = async (tx: Tx) => (await tx.doc(MainAgent, ROOT_CONVERSATION_ID))['$main-agent']
/** The open goal, if any. */
export async function openGoal(tx: Tx) {
@@ -87,7 +87,7 @@ const status = (criterion: Criterion) =>
: criterion.status
/**
* the standing agent's per-run note while a goal is open: the goal, its criteria, and how it works; on a fire of its reminder,
* the main agent's per-run note while a goal is open: the goal, its criteria, and how it works; on a fire of its reminder,
* what to check.
*/
export function noteText(goal: Goal, reminded = false) {
@@ -135,8 +135,8 @@ type ToolArgs = {
summary?: string
}
/** One `goal` tool call by the standing agent (`standing`) in `tx`; returns its answer, and the goal when it finished. */
export async function act(tx: Tx, standing: ConversationId, args: ToolArgs, now: number) {
/** One `goal` tool call by the main agent (`mainAgent`) in `tx`; returns its answer, and the goal when it finished. */
export async function act(tx: Tx, mainAgent: ConversationId, args: ToolArgs, now: number) {
const goal = await openGoal(tx)
if (!goal) throw new Error('No hands-off goal is open; ask the user as usual.')
const criterion = () => {
@@ -163,7 +163,7 @@ export async function act(tx: Tx, standing: ConversationId, args: ToolArgs, now:
...(args.conversation === undefined ? {} : { conversation: args.conversation }),
...(args.images === undefined
? {}
: { images: await imagesEntry(tx, standing, args.images) }),
: { images: await imagesEntry(tx, mainAgent, args.images) }),
}
goal.decisions.push(decision)
return { answer: `Recorded decision ${goal.decisions.length}: ${decision.picked}` }
@@ -203,7 +203,7 @@ export async function act(tx: Tx, standing: ConversationId, args: ToolArgs, now:
throw new Error("finish needs outcome 'met' or 'blocked'")
const ended = await end(tx, {
at: now,
by: 'standing',
by: 'mainAgent',
outcome: args.outcome,
summary: text(args.summary, 'summary'),
})
@@ -213,20 +213,20 @@ export async function act(tx: Tx, standing: ConversationId, args: ToolArgs, now:
}
}
/** The report card's model text: what the standing agent (or the user, ending it) should take from it. */
/** The report card's model text: what the main agent (or the user, ending it) should take from it. */
const reportText = (goal: Goal) =>
goal.ended?.by === 'user'
? `The user ended hands-off goal ${goal.id}. From now on ask them as usual.`
: `Hands-off goal ${goal.id} ended (${goal.ended?.outcome}); its report card is here for the user. From now on ask them as usual.`
/**
* Goal `goal`'s card of `kind` for the standing agent, delivered by a task created in `tx`, the commit that changed the goal.
* The start card says only that the user started it: what the standing agent may do comes from the goal note, built from the
* Goal `goal`'s card of `kind` for the main agent, delivered by a task created in `tx`, the commit that changed the goal.
* The start card says only that the user started it: what the main agent may do comes from the goal note, built from the
* goal as it is when the input runs, so a start still queued after the user ended the goal grants nothing.
*/
async function card(host: Host, tx: Tx, kind: 'goal.started' | 'goal.report', goal: Goal) {
const to = await standingOf(tx)
if (to === undefined) throw new Error('the standing agent has no conversation yet')
const to = await mainAgentOf(tx)
if (to === undefined) throw new Error('the main agent has no conversation yet')
await deliver(host, tx, {
to,
goal: goal.id,
@@ -252,7 +252,7 @@ const sync = (host: Host, tx: Tx, goal: number) =>
},
)
/** Starting and ending a goal is the user's grant; the standing agent only finishes one through its tool. */
/** Starting and ending a goal is the user's grant; the main agent only finishes one through its tool. */
export const userActions = ['start', 'end']
export const actions = {
start: implementAction(StartAction, (args, { host }) =>
@@ -299,7 +299,7 @@ export default function goalExtension(host: Host) {
// Pi upstream: @earendil-works/pi-durable@1.5.0 — patched transactional beforeInput hook.
// A per-run note while a goal is open, never a system-prompt section, keeps the prompt cached.
beforeInput: async (input, { tx, conversationId }) => {
if ((await standingOf(tx)) !== conversationId) return undefined
if ((await mainAgentOf(tx)) !== conversationId) return undefined
const goal = await openGoal(tx)
if (!goal) return undefined
// World automations use a plain input with their fire prefix.
@@ -351,8 +351,8 @@ export default function goalExtension(host: Host) {
async execute(args, api, ctx) {
const caller = api.conversationId
const result = await api.commit(async (tx) => {
if ((await standingOf(tx)) !== caller)
throw new Error('Only the standing agent keeps the hands-off goal.')
if ((await mainAgentOf(tx)) !== caller)
throw new Error('Only the main agent keeps the hands-off goal.')
const acted = await act(tx, caller, args, Date.now())
if (acted.ended) await card(host, tx, 'goal.report', acted.ended)
const changed = acted.sync ?? acted.ended?.id
+6 -6
View File
@@ -7,7 +7,7 @@ import type { ConversationId, ConversationRecord, Cursor, EntryId, JsonObject, M
import type { ModelThinkingLevel } from "@earendil-works/pi-ai"
import { onCommit } from "../messages/compat.ts"
import { Started as BoardStarted } from "../messages/addresses.ts"
import { Standing, Started, Subagents } from "../subagents/api.ts"
import { MainAgent, Started, Subagents } from "../subagents/api.ts"
import { Branch, Branched, LineageApi, Rooted, Roots } from "./api.ts"
import { TallyDoc, SubmissionLanded } from "./refs.ts"
import { readMemory, applyMemoryOps, memoryBrief, memoryIndex, memoryText, memorizeTask, Memorized, MEMORY_RULES, MEMORY_SLUG, LINE_CHARS, BODY_CHARS } from "./memory.ts"
@@ -534,7 +534,7 @@ async function forkBranch(
: await inheritedTally(tx, from, at, root)
let caller: string | undefined
if (input.by !== undefined) {
const index = await tx.doc(Standing, ROOT_CONVERSATION_ID)
const index = await tx.doc(MainAgent, ROOT_CONVERSATION_ID)
const name = Object.keys(index).find((candidate) => index[candidate] === input.by)
caller = `#${input.by}${name ? ` (@${name})` : ''}`
}
@@ -772,7 +772,7 @@ async function refuseTally(tx: Tx, id: ConversationId) {
if (!(await tx.doc(Roots, ROOT_CONVERSATION_ID)).roots.includes(id)) return
const tally = await tx.doc(Tally, id)
if (tally.since !== undefined)
throw new Error(`#${id} is a tally and runs no turns; ask the standing agent to start a branch`)
throw new Error(`#${id} is a tally and runs no turns; ask the main agent to start a branch`)
}
@@ -963,10 +963,10 @@ export default function lineageExtension(host: any) {
// Not replay-safe (the default): an interrupted call is never rerun, so a batch commits at most once.
async execute({ root, ops }, api, ctx) {
// Every conversation sees the tool, but only the primary agent can write memory.
const standing = await host.harness().snapshot(Standing, ROOT_CONVERSATION_ID, ctx)
if (standing?.['$standing'] !== api.conversationId)
const mainAgent = await host.harness().snapshot(MainAgent, ROOT_CONVERSATION_ID, ctx)
if (mainAgent?.['$main-agent'] !== api.conversationId)
throw new Error(
'Only the standing agent writes project memory; tell the standing agent what to remember (message, reply: false).',
'Only the main agent writes project memory; tell the main agent what to remember (message, reply: false).',
)
const id = await memoryRoot(api.conversationId, root, ctx)
const summary = await applyMemoryOps(host, id, ops, Date.now(), `tool:${api.taskId}:${api.callId}`)
+3 -3
View File
@@ -1,6 +1,6 @@
# Messages and lineage
The extensions preserve the durable `board.*` and `lineage.*` identifiers from Standing agent.
The extensions preserve the durable `board.*` and `lineage.*` identifiers from Main agent.
They use world files and world git. They do not use native filesystem or process APIs.
## Branches and delivery
@@ -27,7 +27,7 @@ The starter's final answer reaches the branch without a faulted routing task.
A static test checks free runtime identifiers in every extension module, including function bodies.
The grant chain contains fork ancestry before starter ancestry.
Each conversation occurs once. A root fork that the standing agent starts includes `[branch, root, standing]`.
Each conversation occurs once. A root fork that the main agent starts includes `[branch, root, mainAgent]`.
## Tally and catch-up
@@ -54,7 +54,7 @@ A shared queue serializes writes across extension reloads.
A tool or landing receipt commits in that same file.
Thus, a restart after the rename does not apply the operations twice.
Only the standing agent can use the `remember` tool.
Only the main agent can use the `remember` tool.
Other extensions can use the lineage service.
Branches from a root with memory start with its index and recent landings.
+6 -6
View File
@@ -3,7 +3,7 @@ import type { Context } from '@earendil-works/chord'
import { defineDoc, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
import type { ConversationId } from '@earendil-works/pi-durable'
import type { Host as ExtensionHost } from '../../host/js/extensions.ts'
import { Standing, Subagents, SubagentsApi } from '../subagents/api.ts'
import { MainAgent, Subagents, SubagentsApi } from '../subagents/api.ts'
import { Roots } from '../lineage/api.ts'
import { Tally } from '../lineage/server.ts'
@@ -36,9 +36,9 @@ export async function parentOf(host: ExtensionHost, id: number, ctx: Context) {
}
export async function senderName(host: ExtensionHost, id: number | 'user', ctx: Context) {
if (id === 'user') return 'user'
if (typeof id !== 'number') return 'another Standing agent'
const index = await host.harness().snapshot(Standing, ROOT_CONVERSATION_ID, ctx)
const name = Object.keys(index ?? {}).find((candidate) => candidate !== '$standing' && index?.[candidate] === id)
if (typeof id !== 'number') return 'another Main agent'
const index = await host.harness().snapshot(MainAgent, ROOT_CONVERSATION_ID, ctx)
const name = Object.keys(index ?? {}).find((candidate) => candidate !== '$main-agent' && index?.[candidate] === id)
return `#${id}${name ? ` (@${name})` : ''}`
}
export async function resolve(
@@ -54,9 +54,9 @@ export async function resolve(
const valid = await service.residents(ctx)
if (!valid.some((item) => `@${item.name}` === to))
throw new Error(
`Unknown address ${to}. Valid standing agents: ${valid.map((item) => `@${item.name}`).join(', ') || '(none)'}`,
`Unknown address ${to}. Valid main agents: ${valid.map((item) => `@${item.name}`).join(', ') || '(none)'}`,
)
id = await service.standing(to.slice(1), ctx)
id = await service.mainAgent(to.slice(1), ctx)
} else if (/^#?\d+$/.test(to)) id = Number(to.replace('#', ''))
if (id === undefined) throw new Error(`No recipient for ${to}`)
if (id === from) throw new Error('You cannot message yourself')
+1 -1
View File
@@ -37,7 +37,7 @@ export type Message = {
echo?: true
/** The questions a reply-expected message to the user asks, answered on its card. */
questions?: Question[]
/** A user answer or dismissal that the standing agent settled for the user, not their own click. */
/** A user answer or dismissal that the main agent settled for the user, not their own click. */
via?: Via
}
/** Every message between conversations and the user, on the root conversation. */
+4 -4
View File
@@ -9,7 +9,7 @@ import {
} from '@earendil-works/pi-durable'
import type { ConversationId } from '@earendil-works/pi-durable'
import type { Host as ExtensionHost } from '../../host/js/extensions.ts'
import { Standing, SubagentsApi } from '../subagents/api.ts'
import { MainAgent, SubagentsApi } from '../subagents/api.ts'
import { Messages } from './api.ts'
import type { Message } from './api.ts'
@@ -19,13 +19,13 @@ import { append, terminal } from './log.ts'
export function delivery(host: ExtensionHost) {
const refresh = async (id: number, ctx: Context) => {
const index = await host.harness().snapshot(Standing, ROOT_CONVERSATION_ID, ctx)
const index = await host.harness().snapshot(MainAgent, ROOT_CONVERSATION_ID, ctx)
const name = Object.keys(index ?? {}).find((candidate) => index![candidate] === id)
if (name === undefined) return
const service = host.need(SubagentsApi)
// A retired agent's conversation stays addressable by id until the next open prunes it.
if ((await service.residents(ctx)).some((agent) => agent.name === name))
await service.standing(name, ctx)
await service.mainAgent(name, ctx)
}
return defineTask<
{
@@ -34,7 +34,7 @@ export function delivery(host: ExtensionHost) {
note: boolean
fromLabel: string
toLabel: string
/** Another project's Standing agent answered `message`: deliver only this reply. */
/** Another project's Main agent answered `message`: deliver only this reply. */
reply?: { message: Message; failed: boolean; stopped?: boolean }
},
| { phase: 'deliver' }
+1 -1
View File
@@ -8,7 +8,7 @@ import { Started } from './addresses.ts'
export async function append(tx: Tx, input: Omit<Message, 'id' | 'at'>) {
const log = await tx.doc(Messages, ROOT_CONVERSATION_ID)
// A branch's reply to its starter awaits the starter's answer, which `board.route` sends down; anyone else's reply
// (a standing agent's) just answered and awaits nothing.
// (a main agent's) just answered and awaits nothing.
const awaits =
input.re !== undefined &&
typeof input.from === 'number' &&
+2 -2
View File
@@ -1,4 +1,4 @@
/** Passive context for the standing agent about a branch's questions to the user. */
/** Passive context for the main agent about a branch's questions to the user. */
import { defineTask } from '@earendil-works/pi-durable'
import type { ConversationId } from '@earendil-works/pi-durable'
import type { Host as ExtensionHost } from '../../host/js/extensions.ts'
@@ -14,7 +14,7 @@ export const notice = (host: Pick<ExtensionHost, 'harness'>) =>
call?: string
summary?: string
dismissed?: boolean
/** the standing agent settled it for the user. */
/** the main agent settled it for the user. */
via?: { conversation: number; entry?: number }
}
content: string
+4 -4
View File
@@ -8,7 +8,7 @@ import { ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
import type { ConversationId, Tx } from '@earendil-works/pi-durable'
import { formatAgo } from './compat.ts'
import { Messages } from './api.ts'
import { Standing } from '../subagents/api.ts'
import { MainAgent } from '../subagents/api.ts'
import type { Message } from './api.ts'
import type { Question } from './refs.ts'
@@ -129,8 +129,8 @@ export const gist = (message: Pick<Message, 'text' | 'questions'>) => {
* open, so the agent treats them as unanswered. A per-input entry, not a system-prompt section, keeps the prompt cached.
*/
export async function openQuestionsNote(tx: Tx, conversationId: ConversationId, now: number) {
// the standing agent sees every open question to the user, since it settles those the user decides in its chat.
const primary = (await tx.doc(Standing, ROOT_CONVERSATION_ID))['$standing'] === conversationId
// the main agent sees every open question to the user, since it settles those the user decides in its chat.
const primary = (await tx.doc(MainAgent, ROOT_CONVERSATION_ID))['$main-agent'] === conversationId
const open = (await tx.doc(Messages, ROOT_CONVERSATION_ID)).messages.filter(
(m) => openQuestion(m) && (primary || m.from === conversationId),
)
@@ -143,7 +143,7 @@ export async function openQuestionsNote(tx: Tx, conversationId: ConversationId,
)
.join(
'\n',
)}${primary ? `\nWhen the user decides one in your chat, settle it with standing.action('messages', 'settle', { messageId, text }).` : ''}`
)}${primary ? `\nWhen the user decides one in your chat, settle it with mainAgent.action('messages', 'settle', { messageId, text }).` : ''}`
return {
entries: [
{
+1 -1
View File
@@ -46,7 +46,7 @@ async function live(tx: Tx, kind: string, conversationId?: ConversationId) {
*/
async function busy(tx: Tx, id: ConversationId) {
if ((await tx.doc(LiveDoc, id)).run || (await tx.doc(InboxDoc, id)).items.length) return true
if ((await live(tx, 'standing.subagent-reporter', id)).length) return true
if ((await live(tx, 'main-agent.subagent-reporter', id)).length) return true
return (await live(tx, 'lineage.notify', id)).some(
(task) => (task.input as { wake?: unknown }).wake !== false,
)
+8 -8
View File
@@ -3,7 +3,7 @@ import type { Context } from '@earendil-works/chord'
import { defineDoc, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
import type { ConversationId, EntryId, TaskId } from '@earendil-works/pi-durable'
import type { Host as ExtensionHost } from '../../host/js/extensions.ts'
import { Standing } from '../subagents/api.ts'
import { MainAgent } from '../subagents/api.ts'
import { Messages, MessageSent } from './api.ts'
import type { Question } from './api.ts'
import type { Via } from './refs.ts'
@@ -71,7 +71,7 @@ export async function send(
/** Place it as a note, which starts no run; by default one-way messages to the sender's starter are notes. */
note?: boolean
device?: Device
/** The user's answer or dismissal settled by the standing agent on their behalf, from their input `via.entry`. */
/** The user's answer or dismissal settled by the main agent on their behalf, from their input `via.entry`. */
via?: Via
/** A branch's final answer routed as its report: the branch shows as awaiting its starter. */
report?: boolean
@@ -90,17 +90,17 @@ export async function send(
const sent = await host.harness().snapshot(Sent, taskId, ctx)
if (callId !== undefined && sent?.ids[callId] !== undefined) return sent.ids[callId]
}
const standing = await host.harness().snapshot(Standing, ROOT_CONVERSATION_ID, ctx)
const primaryId = standing?.['$standing']
const mainAgent = await host.harness().snapshot(MainAgent, ROOT_CONVERSATION_ID, ctx)
const primaryId = mainAgent?.['$main-agent']
// The primary agent's messages to a busy conversation almost always steer it: new scope, a decision, a stop.
const steer = input.steer ?? (from !== 'user' && from === primaryId)
// Checked before resolving, which may create a standing agent's conversation.
// Checked before resolving, which may create a main agent's conversation.
const parent = from === 'user' ? undefined : await parentOf(host, from, ctx)
const starter = parent ?? (await starterOf(host, from, ctx))
if (input.to === 'user') {
if (from !== primaryId && !(parent === undefined && starter !== undefined))
throw new Error(
'Only the standing agent and branches message the user; send this to the conversation that started you.',
'Only the main agent and branches message the user; send this to the conversation that started you.',
)
}
if (!input.text.trim()) throw new Error('Message text must not be empty')
@@ -113,7 +113,7 @@ export async function send(
)
const to = await resolve(host, input.to, from, ctx)
const fromLabel = via
? `user (settled for them by the standing agent, from what they said in its chat)`
? `user (settled for them by the main agent, from what they said in its chat)`
: await senderName(host, from, ctx)
const toLabel = await senderName(host, to, ctx)
return host.harness().commit(async (tx) => {
@@ -261,7 +261,7 @@ export async function send(
)
}
// The user's answer or dismissal is their input to the asker; the message's `entry` names it once delivered. One
// the standing agent settled for them is not: their chat input that it came from counted already.
// the main agent settled for them is not: their chat input that it came from counted already.
if (from === 'user' && answerId !== undefined && typeof to === 'number' && !via)
await host.emit(tx, UserInputEvent, {
conversation: to,
+8 -8
View File
@@ -8,7 +8,7 @@ import type { Device } from './compat.ts'
import type { Via } from './refs.ts'
import type { ConversationId, GenerationResult, TaskId } from '@earendil-works/pi-durable'
import type { Host as ExtensionHost } from '../../host/js/extensions.ts'
import { Standing, SubagentsApi } from '../subagents/api.ts'
import { MainAgent, SubagentsApi } from '../subagents/api.ts'
import { LineageApi } from '../lineage/api.ts'
import { Messages, MessagesApi } from './api.ts'
import { gist, openQuestion, openQuestionsNote, questionsParameter } from './questions.ts'
@@ -26,7 +26,7 @@ async function openMessage(host: ExtensionHost, messageId: number, ctx: Context)
if (!message || !openQuestion(message)) throw new Error('These questions are no longer open')
return message
}
/** The user's answer `text` to open message `messageId`, or the standing agent's on their behalf (`via`). */
/** The user's answer `text` to open message `messageId`, or the main agent's on their behalf (`via`). */
async function answer(
host: ExtensionHost,
messageId: number,
@@ -35,7 +35,7 @@ async function answer(
options: { device?: Device; via?: { via: Via; primary: number } } = {},
) {
const message = await openMessage(host, messageId, ctx)
// the standing agent settling its own question has the answer already: a one-way note, not another turn.
// the main agent settling its own question has the answer already: a one-way note, not another turn.
const own = options.via !== undefined && message.from === options.via.primary
return send(
host,
@@ -50,7 +50,7 @@ async function answer(
},
)
}
/** Close open message `messageId` unanswered: the user's dismissal, or the standing agent's on their behalf with `reason`. */
/** Close open message `messageId` unanswered: the user's dismissal, or the main agent's on their behalf with `reason`. */
async function dismiss(
host: ExtensionHost,
messageId: number,
@@ -106,7 +106,7 @@ export default function messages(host: ExtensionHost) {
let by = (await api.snapshot(Started, api.conversationId, ctx))?.by
const down =
by === undefined &&
(await api.snapshot(Standing, ROOT_CONVERSATION_ID, ctx))?.['$standing'] ===
(await api.snapshot(MainAgent, ROOT_CONVERSATION_ID, ctx))?.['$main-agent'] ===
api.conversationId
if (down) by = api.conversationId
if (by === undefined) return undefined
@@ -146,7 +146,7 @@ export default function messages(host: ExtensionHost) {
starter === undefined
? ''
: ` #${starter} started you: when you stop with nothing under way (no subagent, landing or reply pending), your final answer goes to it and wakes it; while work is under way it arrives as a note, and so do your one-way messages to it.`
return `Use message to contact #id or @name. the standing agent and branches may message user. Messages wake their recipient; its answer returns as a reply and wakes you. End your turn instead of waiting. A one-way message (reply: false) gets no answer; use reply: true when you need one. Inputs identify their sender; answer here and your answer is sent to the sender automatically; do not use message to reply. A message to an agent whose message you still owe an answer becomes that answer and ends your run; use it only to attach images.${upward}\n${agents.map((a) => `@${a.name} — ${a.description}`).join('\n')}`
return `Use message to contact #id or @name. the main agent and branches may message user. Messages wake their recipient; its answer returns as a reply and wakes you. End your turn instead of waiting. A one-way message (reply: false) gets no answer; use reply: true when you need one. Inputs identify their sender; answer here and your answer is sent to the sender automatically; do not use message to reply. A message to an agent whose message you still owe an answer becomes that answer and ends your run; use it only to attach images.${upward}\n${agents.map((a) => `@${a.name} — ${a.description}`).join('\n')}`
}),
],
tools: [
@@ -154,7 +154,7 @@ export default function messages(host: ExtensionHost) {
defineTool({
name: 'message',
description:
'Send a message to #id or @name; it returns immediately. A busy recipient gets it after its current run, or during it with steer: true (the default for the standing agent). images forwards screenshots or other images from your conversation, to an agent or to the user. To an agent or the user whose message you owe an answer, it is that answer (with its images and questions) and ends your run.',
'Send a message to #id or @name; it returns immediately. A busy recipient gets it after its current run, or during it with steer: true (the default for the main agent). images forwards screenshots or other images from your conversation, to an agent or to the user. To an agent or the user whose message you owe an answer, it is that answer (with its images and questions) and ends your run.',
parameters: obj({
to: str(),
text: str({ minLength: 1 }),
@@ -196,7 +196,7 @@ export default function messages(host: ExtensionHost) {
}
if (input.to !== 'user')
return { output: [{ type: 'text', text: `Message ${id} sent.` }] }
// The message is the standing agent's answer: the run ends here instead of restating it, unless a reply it has read
// The message is the main agent's answer: the run ends here instead of restating it, unless a reply it has read
// is owed. One still queued gets its own turn.
const owed = await host
.harness()
+38 -42
View File
@@ -1,52 +1,48 @@
# Model selection
The `models` service starts a conversation with `start({agent, cwd, ownership?}, context)`.
The `agent` field names a roster entry in `/etc/sausage/config.yaml`.
The service stores that name in `models.profile`.
`discover(cwd)` exposes the same live definition resolver to subagents.
`configure(tx, id, {agent, inheritedTools, excludeTools?, model?, thinking?})` configures an existing child in its creation transaction.
It retains the parent's tool scope, explicit overrides, and exclusions for subsequent run boundaries.
It applies the definition during creation and before each new run.
Each roster entry names its model and thinking level directly. There are no model families or project overlays.
The `agent` field names `/etc/sausage/roster/<name>.md`.
Each file has YAML frontmatter and a role prompt as its body. The filename supplies the name.
Frontmatter supplies `description`, `model`, `thinking`, and optional tool or isolation settings.
Project definitions use `<repo>/.sausage/roster/*.md`.
An additive project overlay retains the base settings and appends its body.
A project definition with `description` and `model` replaces the base role prompt.
Each resolution reads `/etc/sausage/config.yaml` through `host.fs`.
A file edit applies at the next run boundary without a restart.
The global `prompt` precedes the role-specific `prompt` in every agent's system prompt.
A standing conversation follows the current `standing` roster member, even after a rename.
The service stores the selected name in `models.profile`.
`discover(cwd)` exposes the live definition resolver to subagents.
`configure(tx, id, {agent, inheritedTools, excludeTools?, model?, thinking?})` configures a child within its creation transaction.
It retains inherited tools, explicit overrides, and exclusions at later run boundaries.
Each resolution reads the settings and Markdown files through `host.fs`.
Provider, roster, and prompt edits apply at the next run without a restart.
The global `prompt` precedes the role prompt.
The main agent follows `main_agent_name` in `/etc/sausage/config.yaml`.
To rename it, rename its Markdown file and set `main_agent_name` to the new filename without `.md`.
The main agent retains its conversation and adopts the new definition.
A conversation without a role uses the main agent's model.
The optional `models.pin` service supplies `pin(tx, conversationId)` for an external model override.
Without that provider, the roster entry selects the model.
A conversation without a role uses the standing roster member's model.
No login fallback runs.
Existing registered models retain their transport.
New model references reuse a template from their own registered provider and retain its proxy auth resolver.
They never inherit another provider's transport. Unknown providers fail instead of silently using OpenAI.
Models absent from the catalog receive zero prices, not the template's prices.
The extension never reads a credential or registers a direct provider SDK.
## Providers and metadata
## Host proxy transports and prices
`providers` is a dictionary in `/etc/sausage/config.yaml`.
Each provider has `type`, `base_url`, `api_key`, and `models`.
Supported API types are `anthropic-messages`, `openai-responses`, and `openai-chat-completions`.
Model references use `<provider>/<model>`.
The optional `catalog` field selects a models.dev provider ID. It defaults to the configured provider's name.
Authentication and the base URL come from the config before each request.
Missing keys and unknown providers produce errors that name the config file.
Provider API keys in the config are intentionally visible to agents. Do not print or commit them.
`host/js/proxy-models.ts` reads the authenticated `/v1/models` catalog once at host startup.
OpenAI models use pi-ai's Responses transport. Anthropic models use its Messages transport.
The catalog endpoint determines each model's base URL.
For Anthropic, effort capabilities select adaptive thinking and its advertised effort levels.
Both providers read the proxy key and base URL from `/etc/sausage/config.yaml` before each request.
The usage tag remains unchanged. No provider login or direct endpoint fallback runs.
Empty proxy settings do not prevent startup. A model request names the config file if either setting is empty.
The host refreshes `https://models.dev/api.json` at startup and caches it in `host/models-dev.json`.
A failed refresh uses the cache or the packaged snapshot.
Each model entry is a string ID or an object with `id` and metadata overrides.
Supported overrides are `context_window`, `max_tokens`, `reasoning`, and `cost`.
Cost fields are `input`, `output`, `cache_read`, and `cache_write`, in USD per million tokens.
An unknown model needs all metadata fields. Its error lists missing fields and the config path.
An override retains all catalog values that it does not replace.
Pi-ai computes call costs and stores them in assistant usage. These costs are estimates, not billing totals.
`GET /harness/models` lists configured models and resolved metadata without authentication data.
If the catalog fails or exceeds five seconds, a small table supplies these defaults:
| Provider/model | API | Proxy path |
| --- | --- | --- |
| `openai/gpt-6.1-sol` | Responses | `/v1/responses` |
| `anthropic/claude-opus-5-5` | Messages, adaptive thinking | `/apis/anthropic/v1/messages` |
Catalog prices use USD per million tokens, with conversion from `unit_tokens` when necessary.
The host uses the first advertised route's quote, including cache reads and cache writes.
These costs are estimates, not billing totals. Another proxy route can charge different prices.
Unavailable prices remain zero. Catalog changes apply after a host restart.
Roster edits still apply at the next run boundary without a restart.
`host/tests/it/proxy_models.rs` tests the real transports against a local fake proxy.
`host/tests/it/core_ext.rs` tests these services through the snapshot loader.
`host/tests/it/provider_models.rs` exercises all three real API adapters against a local fake server.
It also checks live provider edits and stored usage costs.
`host/tests/it/core_ext.rs` checks live Markdown edits and main agent renames through the snapshot loader.
+22 -14
View File
@@ -1,34 +1,42 @@
/** Read the world config at every run boundary. All real requests retain the host proxy transport. */
/** Read provider settings and Markdown definitions at each run boundary. */
import { AgentDoc, defineExtension, GenerationTask, hook } from '@earendil-works/pi-durable'
import { CONFIG, definitionRuns, parseConfig, toolsFor } from '../core/agents.ts'
import { CONFIG, ROSTER, definitionRuns, parseConfig, parseDefinition, toolsFor } from '../core/agents.ts'
import { repoRoot } from './files.ts'
import { ModelsApi, Pin, Profile } from './api.ts'
export const order = -200
export default function models(host: any) {
const read = async (_cwd: string) => {
const read = async (cwd: string) => {
const config = parseConfig(await host.fs.read(CONFIG))
return {...config, definitions:config.definitions.map((definition: any)=>({...definition,
resident:definition.name === config.standing || definition.resident,
const definitions = new Map<string, any>()
for (const folder of [ROSTER, `${await repoRoot(host.fs,cwd)}/.sausage/roster`]) {
let entries
try { entries = await host.fs.list(folder) } catch (error) { if (error.code === 'not_found') continue; throw error }
for (const entry of entries) {
if (!entry.name.endsWith('.md') || entry.kind === 'directory') continue
const path = `${folder}/${entry.name}`, name = entry.name.slice(0,-3)
definitions.set(name,parseDefinition(await host.fs.read(path),path,definitions.get(name)))
}
}
if (!definitions.has(config.main_agent_name)) throw new Error(`${CONFIG}: main_agent_name ${config.main_agent_name} needs ${ROSTER}/${config.main_agent_name}.md`)
return {...config, definitions:[...definitions.values()].map((definition: any)=>({...definition,
resident:definition.name === config.main_agent_name || definition.resident,
runs:definitionRuns(definition)})), errors:[], replaced:[]}
}
const register = (ref: any) => {
const store = host.models()
if (store.getModel(ref.provider, ref.modelId)) return
const transport = store.getProvider(ref.provider)
const template = store.getModels(ref.provider)[0]
if (!transport || !template) throw new Error(`No proxy transport for ${ref.provider}/${ref.modelId}`)
const prior = store.getModels(ref.provider)
store.setProvider({...transport, getModels:()=>[...prior, {...template, id:ref.modelId, name:ref.modelId,
cost:{input:0,output:0,cacheRead:0,cacheWrite:0}}]})
throw new Error(`${CONFIG}: unknown provider or unconfigured model ${ref.provider}/${ref.modelId}`)
}
const apply = async (tx: any, id: number) => {
const state = await tx.doc(AgentDoc, id)
const profile = await tx.doc(Profile, id)
await host.models().refreshConfig?.()
const config = await read(state.cwd ?? '/')
if (profile.standing) profile.agent = config.standing
if (profile.mainAgent) profile.agent = config.main_agent_name
const definition = profile.agent ? config.definitions.find(d => d.name === profile.agent) : undefined
if (profile.agent && !definition) throw new Error(`Unknown agent ${profile.agent}: ${JSON.stringify(config.errors)}`)
const selected = (definition ?? config.definitions.find((d: any)=>d.name === config.standing)).runs
const selected = (definition ?? config.definitions.find((d: any)=>d.name === config.main_agent_name)).runs
const pinned = await host.want(Pin)?.pin(tx, id)
const ref = pinned ?? profile.model ?? selected.model
register(ref)
@@ -49,7 +57,7 @@ export default function models(host: any) {
configure: async (tx: any, id: number, options: any) => {
const profile = await tx.doc(Profile,id)
profile.agent = options.agent
profile.standing = options.standing ?? false
profile.mainAgent = options.mainAgent ?? false
profile.inheritedTools = options.inheritedTools
profile.excludeTools = options.excludeTools ?? []
if (options.model) profile.model = options.model
+1 -1
View File
@@ -23,4 +23,4 @@ A manual pause always requires Play.
The timer operates outside the held scheduler.
The stored pause re-arms the timer after restart.
The test module ports all 19 Standing agent pause cases through snapshot-loaded modules.
The test module ports all 19 Main agent pause cases through snapshot-loaded modules.
+1 -1
View File
@@ -1,4 +1,4 @@
/** Pause: whether the user paused this Standing agent, for timers that start work outside the task scheduler. */
/** Pause: whether the user paused this Main agent, for timers that start work outside the task scheduler. */
const defineApi = <S>() => (name: string): {name: string; service?: S} => ({name})
export type PauseService = {
+8 -8
View File
@@ -1,10 +1,10 @@
/** Pause in the browser: whether this Standing agent is paused, what still runs, and Pause and Play. Browser-safe. */
/** Pause in the browser: whether this Main agent is paused, what still runs, and Pause and Play. Browser-safe. */
const actionRef = <A, R>(extension: string, name: string) => ({extension, name})
const docRef = <S>(kind: string) => ({kind})
/**
* The rate limit that paused Standing agent: the provider and model that answered with it, the error as Pi recorded it,
* when (ms), and the conversation it first hit. Also the data of the `pause.limit` card in the standing agent's transcript.
* The rate limit that paused Main agent: the provider and model that answered with it, the error as Pi recorded it,
* when (ms), and the conversation it first hit. Also the data of the `pause.limit` card in the main agent's transcript.
*/
export type RateLimit = {
provider: string
@@ -25,7 +25,7 @@ export type PauseDocState = {
limit?: RateLimit
/** The user pressed Pause during this pause: it never plays by itself. */
manual?: boolean
/** The user's setting: minutes after a rate-limit pause began that Standing agent presses Play by itself; unset is off. */
/** The user's setting: minutes after a rate-limit pause began that Main agent presses Play by itself; unset is off. */
autoPlay?: number
}
@@ -44,7 +44,7 @@ export type RunningTask = {
export type PauseState = {
paused: boolean
since?: number
/** The rate limit that paused Standing agent, if one did. */
/** The rate limit that paused Main agent, if one did. */
limit?: RateLimit
/** Minutes after a rate-limit pause began that Play is pressed by itself; unset is off. */
autoPlay?: number
@@ -66,7 +66,7 @@ export const PauseAction = actionRef<null, PauseState>('pause', 'pause')
/** User-only: continue. Each run that was mid-turn when paused gets one "Paused for …" note. Returns the state. */
export const PlayAction = actionRef<null, PauseState>('pause', 'play')
/**
* User-only `{ minutes }`: play by itself that many minutes (1 to 1440) after a rate limit paused Standing agent, once per
* User-only `{ minutes }`: play by itself that many minutes (1 to 1440) after a rate limit paused Main agent, once per
* pause; `null` turns it off. A pause the user pressed never plays by itself. Returns the state.
*/
export const AutoPlayAction = actionRef<{ minutes: number | null }, PauseState>('pause', 'autoPlay')
@@ -79,9 +79,9 @@ export type PauseNote = { since: number; until: number; finished?: boolean; auto
/**
* The `pause.held` entry in a subagent's parent, mid-turn when the subagent hit a rate limit: subagent `child` hit
* `provider`'s limit for `model` at `at` (ms), and Standing agent is paused until Play.
* `provider`'s limit for `model` at `at` (ms), and Main agent is paused until Play.
*/
export type HeldNote = { child: number; provider: string; model: string; at: number }
/** The `pause.autoplay` entry in the standing agent's transcript: the rate-limit pause from `since` played by itself at `at`. */
/** The `pause.autoplay` entry in the main agent's transcript: the rate-limit pause from `since` played by itself at `at`. */
export type AutoPlayed = { since: number; at: number; minutes: number }
+17 -17
View File
@@ -10,7 +10,7 @@ type ExtensionHost = any
const implementAction = (_token: any, handler: any) => handler
import { isRetryableAssistantError } from '@earendil-works/pi-ai'
const rateLimited = (error: string) => /(?<![a-z0-9])429(?![a-z0-9])|rate.?limit|too many requests/i.test(error) && isRetryableAssistantError({stopReason:'error',errorMessage:error} as any)
import { Standing } from '../subagents/api.ts'
import { MainAgent } from '../subagents/api.ts'
import { PauseApi } from './api.ts'
import { lasted } from './lasted.ts'
import { AutoPlayAction, PauseAction, PauseDoc, PlayAction, StateAction } from './refs.ts'
@@ -63,9 +63,9 @@ function serial<T>(host: Pick<ExtensionHost, 'resource'>, work: () => Promise<T>
* while paused and execute nothing meanwhile, so they never keep the pause from being safe.
*/
const PASSIVE: Record<string, string[]> = {
'standing.subagent-reporter': ['deliver', 'retry'],
'standing.automation-scheduler': ['sleep'],
'standing.automation-run': ['deliver'],
'main-agent.subagent-reporter': ['deliver', 'retry'],
'main-agent.automation-scheduler': ['sleep'],
'main-agent.automation-run': ['deliver'],
'board.delivery': ['deliver', 'reply'],
'board.route': ['observe'],
'conversations.summary': ['observe'],
@@ -213,7 +213,7 @@ function arm(host: Host, doc: PauseDocState) {
/**
* Play the pause by itself once its time came (`now`, for tests), once: Play ends the pause, and a limit that still
* holds pauses again with a new start and a full grace period. Tells the standing agent with a `pause.autoplay` entry first,
* holds pauses again with a new start and a full grace period. Tells the main agent with a `pause.autoplay` entry first,
* while the pause is still recorded: a crash before Play reopens paused, re-arms the timer, and the entry's request ID
* keeps the second announcement from repeating.
*/
@@ -224,10 +224,10 @@ export async function autoPlay(host: Host, now = Date.now()) {
if (at === undefined || now < at) return false
const data: AutoPlayed = { since: doc.since!, at: now, minutes: doc.autoPlay! }
const harness = host.harness()
const standing = (await harness.snapshot(Standing, ROOT_CONVERSATION_ID, context))?.['$standing']
if (standing !== undefined)
const mainAgent = (await harness.snapshot(MainAgent, ROOT_CONVERSATION_ID, context))?.['$main-agent']
if (mainAgent !== undefined)
await (
await harness.conversation(standing, context)
await harness.conversation(mainAgent, context)
)?.submit(
{
type: 'write',
@@ -238,7 +238,7 @@ export async function autoPlay(host: Host, now = Date.now()) {
model: [
{
role: 'user',
content: `Standing agent played by itself ${lasted(now - data.since)} after the rate limit paused it (the user's setting: ${data.minutes} min).`,
content: `Main agent played by itself ${lasted(now - data.since)} after the rate limit paused it (the user's setting: ${data.minutes} min).`,
timestamp: now,
},
],
@@ -251,7 +251,7 @@ export async function autoPlay(host: Host, now = Date.now()) {
})
}
/** The user's setting: play by itself `minutes` after a rate limit paused Standing agent; `null` turns it off. */
/** The user's setting: play by itself `minutes` after a rate limit paused Main agent; `null` turns it off. */
async function setAutoPlay(host: Host, minutes: unknown) {
if (
minutes !== null &&
@@ -268,7 +268,7 @@ async function setAutoPlay(host: Host, minutes: unknown) {
}
/**
* A model answered with a rate limit: pause, so nothing retries into it, and tell the standing agent with one `pause.limit`
* A model answered with a rate limit: pause, so nothing retries into it, and tell the main agent with one `pause.limit`
* card. Pi has already scheduled that generation's retry; the hold parks it, and every other run at its next step,
* until Play. The caller holds the scheduler already, in the commit's publication, before that retry's timer can fire.
*/
@@ -318,12 +318,12 @@ async function held(host: Host, limit: RateLimit) {
/** The `pause.limit` card for the limit that paused the host, once per pause (its request ID is the pause's start). */
async function card(host: Host, doc: PauseDocState) {
const harness = host.harness()
const standing = (await harness.snapshot(Standing, ROOT_CONVERSATION_ID, context))?.['$standing']
if (!doc.paused || !doc.limit || standing === undefined) return
// Only kind and data, so Pi appends it at once even while the standing agent's own run is held; the model learns of the limit
const mainAgent = (await harness.snapshot(MainAgent, ROOT_CONVERSATION_ID, context))?.['$main-agent']
if (!doc.paused || !doc.limit || mainAgent === undefined) return
// Only kind and data, so Pi appends it at once even while the main agent's own run is held; the model learns of the limit
// from the note Play leaves on each held run.
await (
await harness.conversation(standing, context)
await harness.conversation(mainAgent, context)
)?.submit(
{
type: 'write',
@@ -380,7 +380,7 @@ async function play(host: Host, auto = false) {
const content =
how === 'finished'
? `Paused for ${span} after this run's last answer.`
: `Paused for ${span}; the run continued after ${auto ? 'Standing agent played by itself' : 'Play'}.`
: `Paused for ${span}; the run continued after ${auto ? 'Main agent played by itself' : 'Play'}.`
const conversation = await harness.conversation(id, context)
await conversation?.submit(
{
@@ -472,7 +472,7 @@ export default function pauseExtension(host: Host) {
flag(host).paused = true
host.harness().hold()
serial(host, () => limited(host, limit)).catch((error: unknown) => {
// A failed pause released its hold (limited); a failed card leaves the pause standing in the status line.
// A failed pause released its hold (limited); a failed card leaves the pause mainAgent in the status line.
console.error('pause: pausing for a rate limit failed:', error)
})
}
+2 -2
View File
@@ -1,6 +1,6 @@
# Plan
The `plan` tool maintains a durable epic/task ledger in `standing.plan`.
The `plan` tool maintains a durable epic/task ledger in `main-agent.plan`.
Task-owned descendants read their top owner's ledger. Only that owner edits it.
Ownerless conversations maintain separate ledgers.
@@ -8,7 +8,7 @@ Edits and receipts commit together. Memo namespaces distinguish nested calls tha
The tool returns both JSON text and structured content.
The compact prompt section excludes completed epics.
The root document `standing.plan.tasks` indexes linked child conversations.
The root document `main-agent.plan.tasks` indexes linked child conversations.
A new link or task title change updates the child's metadata title.
Other edits preserve a title that the user supplied.
+3 -3
View File
@@ -37,7 +37,7 @@ export type Epic = {
tasks: Record<string, Task>
}
export const Plan = defineDoc<{ next: number; epics: Record<string, Epic> }>({
kind: 'standing.plan',
kind: 'main-agent.plan',
checkpointWhen: () => false,
version: 1,
scope: 'conversation',
@@ -54,7 +54,7 @@ export type TaskLink = {
status: Task['status']
}
export const TaskIndex = defineDoc<{ children: Record<string, TaskLink> }>({
kind: 'standing.plan.tasks',
kind: 'main-agent.plan.tasks',
checkpointWhen: () => false,
version: 1,
scope: 'conversation',
@@ -65,7 +65,7 @@ export const TaskIndex = defineDoc<{ children: Record<string, TaskLink> }>({
export const docs = [Plan, TaskIndex]
// Commit the receipt with the edit, closing the crash window before api.memo stores the result.
const Receipt = defineDoc<{ results: Record<string, string> }>({
kind: 'standing.plan.receipt',
kind: 'main-agent.plan.receipt',
checkpointWhen: () => false,
version: 1,
scope: 'task',
+1 -1
View File
@@ -22,6 +22,6 @@ The recovery observer and startup scan deliver answers to one-way parent message
They also recover answers after interrupted or failed delivery.
They do not report ordinary child inputs or messages from other conversations.
The service creates standing conversations for resident definitions.
The service creates main agent conversations for resident definitions.
The sections list available definitions and child work that remains active.
The definitions editor, workspaces, `land`, and `discard` remain outside this extension's current scope.
+14 -14
View File
@@ -1,16 +1,16 @@
/**
* Subagents: conversations delegated to agent definitions, and the standing agents. Other extensions create
* configured child conversations and open standing agents here; definitions themselves are read with `lib/agents.ts`.
* Subagents: conversations delegated to agent definitions, and the main agents. Other extensions create
* configured child conversations and open main agents here; definitions themselves are read with `lib/agents.ts`.
*/
import type { Context } from '@earendil-works/chord'
import { defineDoc } from '@earendil-works/pi-durable'
import type { ConversationId, EntryId, TaskId, Tx } from '@earendil-works/pi-durable'
const defineApi = <S>() => (name: string): {name: string; service?: S} => ({name})
import { RunsDoc, StandingDoc } from './refs.ts'
import { RunsDoc, MainAgentDoc } from './refs.ts'
import type { Run } from './refs.ts'
import type { createChild } from './children.ts'
export { Started, StandingDoc } from './refs.ts'
export { Started, MainAgentDoc } from './refs.ts'
/** Each conversation's subagents and pending subagent actions, on the conversation that started them. */
export const Subagents = defineDoc<{
@@ -38,7 +38,7 @@ export const Subagents = defineDoc<{
}
>
}>({
kind: 'standing.subagents',
kind: 'main-agent.subagents',
checkpointWhen: () => false,
version: 1,
scope: 'conversation',
@@ -47,9 +47,9 @@ export const Subagents = defineDoc<{
initial: () => ({ agents: {}, actions: {} }),
})
/** `{ [name]: conversationId }` of the standing agents, on the root conversation. */
export const Standing = defineDoc<Record<string, ConversationId>>({
kind: StandingDoc.kind,
/** `{ [name]: conversationId }` of the main agents, on the root conversation. */
export const MainAgent = defineDoc<Record<string, ConversationId>>({
kind: MainAgentDoc.kind,
checkpointWhen: () => false,
version: 1,
scope: 'conversation',
@@ -70,11 +70,11 @@ export const Runs = defineDoc<Record<string, Run>>({
})
/**
* The definition a conversation was configured from: children (including automation runs) and standing agents. An agent
* The definition a conversation was configured from: children (including automation runs) and main agents. An agent
* made on the fly has none: `adhoc`, so nothing looks a definition up by its label.
*/
export const Profile = defineDoc<{ definition?: string; adhoc?: true }>({
kind: 'standing.agent-profile',
kind: 'main-agent.agent-profile',
checkpointWhen: () => false,
version: 1,
scope: 'conversation',
@@ -84,16 +84,16 @@ export const Profile = defineDoc<{ definition?: string; adhoc?: true }>({
})
export type SubagentsService = {
/** The standing agents that run in this home, by name and description; use it to list valid `@name` addresses. */
/** The main agents that run in this home, by name and description; use it to list valid `@name` addresses. */
residents(ctx: Context): Promise<{ name: string; description: string }[]>
/** The conversation of standing agent `name`, created or refreshed from its definition; throws for unknown names. */
standing(name: string, ctx: Context): Promise<ConversationId>
/** The conversation of main agent `name`, created or refreshed from its definition; throws for unknown names. */
mainAgent(name: string, ctx: Context): Promise<ConversationId>
/** Create, inside `tx`, a child conversation configured from a definition or as a copy of the parent's agent. */
createChild: (
tx: Tx,
options: Parameters<typeof createChild>[2],
) => ReturnType<typeof createChild>
}
/** Child conversations and standing agents; `need` it where delegation is required, else `want` it. */
/** Child conversations and main agents; `need` it where delegation is required, else `want` it. */
export const SubagentsApi = defineApi<SubagentsService>()('subagents')
export const tier = 'core'
+3 -3
View File
@@ -3,7 +3,7 @@ import { AgentDoc, configure } from '@earendil-works/pi-durable'
import { ModelsApi } from '../models/api.ts'
import { Started } from './refs.ts'
import { Profile } from './api.ts'
export async function started(host: any, tx: any, conversation: any, agent: string, kind: 'child'|'standing'|'branch') {
export async function started(host: any, tx: any, conversation: any, agent: string, kind: 'child'|'mainAgent'|'branch') {
const {model, thinkingLevel} = await tx.doc(AgentDoc, conversation.id)
const parent = conversation.owner?.conversationId ?? conversation.parent?.conversationId
await host.emit(tx, Started, {id:conversation.id, agent, kind,
@@ -21,12 +21,12 @@ export async function createChild(host: any, tx: any, options: any) {
if (definition?.summaries === false) await host.want({name:'conversations'})?.setAutomaticSummaries(tx, child.id, false)
return child
}
/** Standing refreshes and new children share the models service and persistent definition profile. */
/** MainAgent refreshes and new children share the models service and persistent definition profile. */
export async function configureChild(host: any, tx: any, id: number, options: any) {
await configure(tx, id, childConfiguration(options))
if (options.definition) await host.need(ModelsApi).configure(tx, id, {
agent:options.definition.name,
standing:options.standing ?? false,
mainAgent:options.mainAgent ?? false,
inheritedTools:options.parentAgent.tools.map((tool: any)=>tool.name),
excludeTools:options.excludeTools,
model:options.model,
+4 -4
View File
@@ -1,13 +1,13 @@
/**
* Where a page opens when no conversation is chosen (a first load, a new chapter, a remembered one that is gone):
* the standing agent's conversation, once the shared state and the standing agents are known; 'create' when the standing agent has no
* the main agent's conversation, once the shared state and the main agents are known; 'create' when the main agent has no
* conversation yet, as on a fresh preview. The root is only its history.
*/
export const landing = (
loaded: boolean,
chosen: unknown,
standing: Readonly<Record<string, number>> | null | undefined,
mainAgent: Readonly<Record<string, number>> | null | undefined,
): number | 'create' | undefined => {
if (!loaded || chosen !== undefined || standing === undefined) return undefined
return standing?.['$standing'] ?? 'create'
if (!loaded || chosen !== undefined || mainAgent === undefined) return undefined
return mainAgent?.['$main-agent'] ?? 'create'
}
+2 -2
View File
@@ -1,6 +1,6 @@
/** Browser-safe identities for child starts and durable indices. */
import { defineEvent } from '@earendil-works/sausage-events'
export const Started = defineEvent<{id:number; agent:string; model?:string; thinking?:string; parent?:number; kind:'child'|'standing'|'branch'}>('subagents.started')
export const Started = defineEvent<{id:number; agent:string; model?:string; thinking?:string; parent?:number; kind:'child'|'mainAgent'|'branch'}>('subagents.started')
export type Run = {model?:string; adhoc?:true}
export const RunsDoc = {kind:'subagents.runs'}
export const StandingDoc = {kind:'standing.standing-agents'}
export const MainAgentDoc = {kind:'main-agent.index'}
+19 -19
View File
@@ -5,17 +5,17 @@ import { bool, int, obj, oneOf, opt, str } from '@earendil-works/pi-schema'
import type { AssistantMessage } from '@earendil-works/pi-ai'
import { AgentDoc, AssistantEntry, ToolResultEntry, defineExtension, defineTask, defineTool, LiveDoc, InboxDoc, GenerationTask, hook, ROOT_CONVERSATION_ID, section } from '@earendil-works/pi-durable'
import type { ConversationId, Cursor, EntryId, EntryRecord, TaskId, ToolExecutionApi, SubmissionRecord } from '@earendil-works/pi-durable'
import { Subagents, Standing, Profile, Runs, SubagentsApi } from './api.ts'
import { Subagents, MainAgent, Profile, Runs, SubagentsApi } from './api.ts'
import { createChild, configureChild, started } from './children.ts'
import { ModelsApi } from '../models/api.ts'
export const docs = [Subagents, Standing, Profile, Runs]
export const docs = [Subagents, MainAgent, Profile, Runs]
export const runsLabel = (runs: any) => runs ? `${runs.model.provider}/${runs.model.modelId}${runs.thinking ? ` · ${runs.thinking}` : ''}` : ''
const onCommit = (harness: any, callback: any) => {
const observation = harness.observeCommits({advance: callback, end: () => {}})
return () => observation.detach()
}
const Anchor = defineTask<null, { phase: 'done' }, null>({
name: 'standing.subagent-anchor',
name: 'main-agent.subagent-anchor',
version: 1,
initial: () => ({ phase: 'done' }),
phases: {
@@ -63,7 +63,7 @@ function createReporter() {
{ phase: 'deliver' } | { phase: 'report'; report?: string; answer?: EntryId },
null
>({
name: 'standing.subagent-reporter',
name: 'main-agent.subagent-reporter',
version: 1,
initial: (input) =>
input.answer === undefined
@@ -306,34 +306,34 @@ export default function subagents(host: any) {
return child
},
residents: async () => (await models.discover('/src/sausage')).definitions.filter((d: any) => d.resident).map(({name, description}: any) => ({name, description})),
standing: async (name: string, ctx: Context) => {
mainAgent: async (name: string, ctx: Context) => {
const found = await models.discover('/src/sausage')
const definition = found.definitions.find((d: any) => d.name === name && d.resident)
if (!definition) throw new Error(`Unknown standing agent @${name}`)
if (!definition) throw new Error(`Unknown main agent @${name}`)
const root = (await host.harness().conversation(ROOT_CONVERSATION_ID, ctx))!
const parentAgent = await root.agent(ctx)
return host.harness().commit(async (tx: any) => {
const index = await tx.doc(Standing, ROOT_CONVERSATION_ID)
const primary = name === found.standing
const key = primary ? '$standing' : name
const index = await tx.doc(MainAgent, ROOT_CONVERSATION_ID)
const primary = name === found.main_agent_name
const key = primary ? '$main-agent' : name
const existing = index[key] && await tx.conversation(index[key])
const lifecycle = host.want({name:'conversations'})
if (existing) {
await lifecycle?.setStanding(tx, existing.id, true)
await lifecycle?.setMainAgent(tx, existing.id, true)
await lifecycle?.setAutomaticSummaries(tx, existing.id, false)
if (primary) for (const alias of Object.keys(index)) if (alias !== '$standing' && index[alias] === existing.id) delete index[alias]
if (primary) for (const alias of Object.keys(index)) if (alias !== '$main-agent' && index[alias] === existing.id) delete index[alias]
index[name] = existing.id
const metadata = await tx.doc(host.metadata, existing.id)
if (metadata.label !== name) Object.assign(metadata, {label:name, title:`@${name}`})
await configureChild(host, tx, existing.id, {definition, parentAgent, standing:primary})
await configureChild(host, tx, existing.id, {definition, parentAgent, mainAgent:primary})
return existing.id
}
const child = await createChild(host, tx, {owner: {kind:'ownerless'}, definition, parentAgent, metadata:host.metadata, label:name, title:`@${name}`, standing:primary})
const child = await createChild(host, tx, {owner: {kind:'ownerless'}, definition, parentAgent, metadata:host.metadata, label:name, title:`@${name}`, mainAgent:primary})
index[key] = child.id
index[name] = child.id
await lifecycle?.setStanding(tx, child.id, true)
await lifecycle?.setMainAgent(tx, child.id, true)
await lifecycle?.setAutomaticSummaries(tx, child.id, false)
await started(host, tx, child, name, 'standing')
await started(host, tx, child, name, 'mainAgent')
return child.id
}, ctx)
},
@@ -434,13 +434,13 @@ export default function subagents(host: any) {
host.onOpen(async () => {
const found = await models.discover('/src/sausage')
if (!found.errors.length) await host.harness().commit(async (tx: any) => {
const index = await tx.doc(Standing, ROOT_CONVERSATION_ID)
for (const name of Object.keys(index)) if (name !== '$standing' && !found.definitions.some((d: any) => d.name === name && d.resident)) {
const index = await tx.doc(MainAgent, ROOT_CONVERSATION_ID)
for (const name of Object.keys(index)) if (name !== '$main-agent' && !found.definitions.some((d: any) => d.name === name && d.resident)) {
const id = index[name]
delete index[name]
if (id !== index['$standing'] && await tx.conversation(id)) {
if (id !== index['$main-agent'] && await tx.conversation(id)) {
delete (await tx.doc(host.metadata, id)).label
await host.want({name:'conversations'})?.setStanding(tx, id, false)
await host.want({name:'conversations'})?.setMainAgent(tx, id, false)
}
}
}, BACKGROUND_CONTEXT)
+5 -5
View File
@@ -1,17 +1,17 @@
# Todos
The root document `todos` stores the world's to-do list.
Each item belongs to `user` or `standing`.
Each item belongs to `user` or `mainAgent`.
The `todo` tool adds items, completes items, and lists open items by owner.
Text must contain 1–300 characters after the tool trims it.
The list retains the newest 50 completed items. The exported `mark` helper also reopens items.
Only the standing `standing` conversation uses the tool.
The reader uses the existing `standing.standing-agents` document contract.
Only the main agent conversation uses the tool.
The reader uses the existing `main-agent.index` document contract.
Until subagents supplies that document, no conversation qualifies automatically.
This port does not designate the root as `standing`.
This port does not designate the root as `mainAgent`.
The `beforeInput` hook places a note only when the standing conversation's open list changes.
The `beforeInput` hook places a note only when the main agent conversation's open list changes.
The note and the remembered list commit with the input.
The tool remains deferred. A caller must load it through `modelTools` or a tool-search extension.
+17 -17
View File
@@ -1,4 +1,4 @@
/** The world's root to-do list, its todo tool, and changed-list notes for the standing conversation. */
/** The world's root to-do list, its todo tool, and changed-list notes for the mainAgent conversation. */
import { int, obj, oneOf, opt, str } from '@earendil-works/pi-schema'
import {
defineDoc,
@@ -9,12 +9,12 @@ import {
ROOT_CONVERSATION_ID,
} from '@earendil-works/pi-durable'
import type { ConversationId, Tx } from '@earendil-works/pi-durable'
// Read subagents' standing-agent contract without requiring its extension yet.
export const Standing = defineDoc<Record<string, ConversationId>>({
kind: 'standing.standing-agents', checkpointWhen: () => false, version: 1,
// Read subagents' mainAgent-agent contract without requiring its extension yet.
export const MainAgent = defineDoc<Record<string, ConversationId>>({
kind: 'main-agent.index', checkpointWhen: () => false, version: 1,
scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({}),
})
export type Todo = { id: number; text: string; owner: 'user' | 'standing'; at: number; done?: number }
export type Todo = { id: number; text: string; owner: 'user' | 'mainAgent'; at: number; done?: number }
/** Done to-dos kept; older ones are dropped when another is ticked. */
export const DONE_LIMIT = 50
@@ -29,7 +29,7 @@ export const Todos = defineDoc<{ next: number; items: Todo[] }>({
fork: 'initial',
initial: () => ({ next: 1, items: [] }),
})
/** The open list the standing agent last heard, so it hears again only after a change. */
/** The open list the main agent last heard, so it hears again only after a change. */
const Seen = defineDoc<{ key?: string }>({
kind: 'todos.seen',
checkpointWhen: () => false,
@@ -41,14 +41,14 @@ const Seen = defineDoc<{ key?: string }>({
})
export const docs = [Todos]
const OWNERS = ['user', 'standing'] as const
const OWNERS = ['user', 'mainAgent'] as const
export async function add(tx: Tx, text: unknown, owner: unknown) {
if (typeof text !== 'string' || !text.trim()) throw new Error('text must be nonempty')
if (text.trim().length > TEXT_CHARS)
throw new Error(`text must be at most ${TEXT_CHARS} characters`)
if (!OWNERS.includes(owner as Todo['owner']))
throw new Error("owner must be 'user' or 'standing'")
throw new Error("owner must be 'user' or 'mainAgent'")
const todos = await tx.doc(Todos, ROOT_CONVERSATION_ID)
const todo: Todo = {
id: todos.next++,
@@ -85,7 +85,7 @@ const line = (todo: Todo) => `- ${todo.id}: ${todo.text}`
export function listText(items: readonly Todo[]) {
const open = items.filter((item) => item.done === undefined)
if (!open.length) return undefined
const mine = open.filter((item) => item.owner === 'standing')
const mine = open.filter((item) => item.owner === 'mainAgent')
const theirs = open.filter((item) => item.owner === 'user')
return [
...(mine.length ? ['Yours:', ...mine.map(line)] : []),
@@ -93,7 +93,7 @@ export function listText(items: readonly Todo[]) {
].join('\n')
}
/** the standing agent's note when the open list changed since it last heard it; undefined otherwise. */
/** the main agent's note when the open list changed since it last heard it; undefined otherwise. */
export async function noteFor(tx: Tx, conversationId: ConversationId, now: number) {
const items = (await tx.doc(Todos, ROOT_CONVERSATION_ID)).items
const text = listText(items)
@@ -120,8 +120,8 @@ export async function noteFor(tx: Tx, conversationId: ConversationId, now: numbe
}
}
const standing = (tx: Tx, id: ConversationId) =>
tx.doc(Standing, ROOT_CONVERSATION_ID).then((doc) => doc['$standing'] === id)
const mainAgent = (tx: Tx, id: ConversationId) =>
tx.doc(MainAgent, ROOT_CONVERSATION_ID).then((doc) => doc['$main-agent'] === id)
export default function todosExtension() {
return defineExtension({
@@ -131,7 +131,7 @@ export default function todosExtension() {
// Pi upstream: @earendil-works/pi-durable@1.5.0 — patched transactional beforeInput hook.
// A per-input note when the list changed, never a system-prompt section, keeps the prompt cached.
beforeInput: async (_input, { tx, conversationId }) =>
(await standing(tx, conversationId))
(await mainAgent(tx, conversationId))
? noteFor(tx, conversationId, Date.now())
: undefined,
}),
@@ -141,11 +141,11 @@ export default function todosExtension() {
name: 'todo',
deferred: true,
description:
"The world's to-dos, owned by you ('standing') or the user ('user'): add {text, owner}, done {id}, list. Keep open loops here. Only the standing conversation maintains this list.",
"The world's to-dos, owned by you ('mainAgent') or the user ('user'): add {text, owner}, done {id}, list. Keep open loops here. Only the mainAgent conversation maintains this list.",
parameters: obj({
action: oneOf(['add', 'done', 'list']),
text: opt(str()),
owner: opt(oneOf(['user', 'standing'])),
owner: opt(oneOf(['user', 'mainAgent'])),
id: opt(int({ minimum: 1 })),
}),
// Not replay-safe (the default): an interrupted add is never rerun, so it commits at most once.
@@ -154,9 +154,9 @@ export default function todosExtension() {
const caller = api.conversationId
const text = await api.commit(async (tx) => {
// Deferred only hides it: a branch can still find it with tool_search, so the caller is checked here.
if (!(await standing(tx, caller)))
if (!(await mainAgent(tx, caller)))
throw new Error(
'Only the standing agent keeps the to-dos; ask the standing agent to add one.',
'Only the main agent keeps the to-dos; ask the main agent to add one.',
)
if (args.action === 'add') {
const todo = await add(tx, args.text, args.owner)
+3 -3
View File
@@ -6,13 +6,13 @@ A temporary provider failure creates a durable `watchdog.failure` task. The task
A failed continuation does not receive another retry. Permanent errors report immediately.
If another run remains active at the deadline, watchdog does not submit a continuation.
Reports use the messages service with a stable request ID. Owned children report to their owner. Other conversations report to the standing agent.
A failure of the standing agent itself produces a notice instead. The service retains the latest 20 reported failures.
Reports use the messages service with a stable request ID. Owned children report to their owner. Other conversations report to the main agent.
A failure of the main agent itself produces a notice instead. The service retains the latest 20 reported failures.
Receipts and a high-water mark prevent repeated recovery. Fresh installation excludes historical failures.
On reopen, watchdog recovers failures that its observer missed. Retry inputs also use stable request IDs.
Before the standing agent receives an input, watchdog can add an idle-branch note. It does not modify the system prompt.
Before the main agent receives an input, watchdog can add an idle-branch note. It does not modify the system prompt.
The note excludes active branches, concluded branches, user questions, and queued landings when workspaces is available.
It waits 30 minutes, or two hours while a branch awaits an agent reply. It repeats only when the branch IDs change.
+1 -1
View File
@@ -58,7 +58,7 @@ export async function idleNote(
return false
}
const idle = []
// Branches are ownerless, so subagents are skipped before any document read: this runs before every the standing agent input.
// Branches are ownerless, so subagents are skipped before any document read: this runs before every the main agent input.
for (const conversation of conversations) {
if (conversation.owner || excluded.has(conversation.id)) continue
if (!(await tx.doc(Branch, conversation.id)).branch) continue
+1 -1
View File
@@ -128,7 +128,7 @@ import type { AssistantMessage } from '@earendil-works/pi-ai'
/**
* Whether a model error is a rate limit Pi retries: HTTP 429, "rate limit", "too many requests". Retrying sooner only
* hits the limit again, so Standing agent pauses until the user presses Play, and the held run continues through Pi's retry.
* hits the limit again, so Main agent pauses until the user presses Play, and the held run continues through Pi's retry.
* Quota, usage-limit and billing errors are on Pi's no-retry list: they end the run at once and fail as usual.
* Overloaded servers (529) are the provider's capacity, not the account's limit.
*/
+5 -5
View File
@@ -24,7 +24,7 @@ import type {
import type { Host as ExtensionHost } from '../../host/js/extensions.ts'
import { rateLimited } from './retry.ts'
import { MessagesApi, Messages } from '../messages/api.ts'
import { Standing, Subagents } from '../subagents/api.ts'
import { MainAgent, Subagents } from '../subagents/api.ts'
import { idleNote } from './idle.ts'
import { WatchdogApi } from './api.ts'
import type { ReportedFailure } from './api.ts'
@@ -310,10 +310,10 @@ export default function watchdog(host: ExtensionHost) {
)
return
const child = (await tx.conversation(task.conversationId))!
const standing = (await tx.doc(Standing, ROOT_CONVERSATION_ID))['$standing']
const recipient = child.owner?.conversationId ?? standing
const mainAgent = (await tx.doc(MainAgent, ROOT_CONVERSATION_ID))['$main-agent']
const recipient = child.owner?.conversationId ?? mainAgent
if (recipient === undefined)
throw new Error('Watchdog needs the standing agent to report a failed run')
throw new Error('Watchdog needs the main agent to report a failed run')
if (task.state.status !== 'terminal' || task.state.outcome.status !== 'failed') return
const error = task.state.outcome.error.message
const { hint, temporary } = diagnose(error, info.assistant)
@@ -412,7 +412,7 @@ export default function watchdog(host: ExtensionHost) {
hooks: [
hook(GenerationTask, {
beforeInput: async (_input, { tx, conversationId }) =>
(await tx.doc(Standing, ROOT_CONVERSATION_ID))['$standing'] === conversationId
(await tx.doc(MainAgent, ROOT_CONVERSATION_ID))['$main-agent'] === conversationId
? idleNote(tx, host, conversationId, Date.now())
: undefined,
}),
+1 -1
View File
@@ -4,7 +4,7 @@ import { defineEvent } from '@earendil-works/sausage-events'
export type Workspace = {source:string;target:string;path:string;branch:string;base?:string;state:'preparing'|'ready'|'failed'|'removed';preparation?:number;error?:string;landed?:string}
export type Submission = {id:string;conversationId:number;source:string;target:string;tip:string;at:number;divergence:string;title:string;conversations:string[];prepared?:{base:string;sha:string};landed?:{base:string;sha:string;commits:{sha:string;subject:string}[];paths:string[]};outcome?:any}
export const Workspaces = defineDoc<{items:Record<string,Workspace>;queue:Submission[];landers:Record<string,number>;landerConversations:Record<string,number>;results:Record<string,any>}>({
kind:'standing.workspaces',version:1,scope:'conversation',history:'latest',fork:'initial',checkpointWhen:()=>false,
kind:'main-agent.workspaces',version:1,scope:'conversation',history:'latest',fork:'initial',checkpointWhen:()=>false,
initial:()=>({items:{},queue:[],landers:{},landerConversations:{},results:{}}),
})
export const WorkspacesApi = {name:'workspaces'}
+10 -9
View File
@@ -37,7 +37,7 @@ CARGO_TARGET_DIR=target/harness nix develop -c cargo test -p sausage --test harn
The lock file pins npm dependencies. The bundle command requires Nushell, GNU patch, and npm.
It applies `patches/pi-durable-1.5.0.patch` before esbuild runs.
This is Standing agent's patch at `7ec0b4f`, without the Node execution environment hunks (D65).
This is Ultimate's patch at `7ec0b4f`, without the Node execution environment hunks (D65).
The patch adds transactional `beforeInput`, input markers, scheduler hold/release, and the failed-run boundary fix.
The step verifies an existing application before another bundle build. A mismatched or partially applied patch fails the command.
`npm ci` restores the unpatched dependency. The next bundle command applies the patch again.
@@ -48,13 +48,14 @@ For Nix, use a separate npm derivation with `importNpmLock` and the checked-in l
Run the bundle script there. Pass its output to the Rust source derivation before Crane builds the host.
The Rust build must not execute npm or download JavaScript packages.
The console reads the `llm-proxy` credentials item through Rust.
Its public `base_url` field contains the proxy URL. Its secret `api_key` field contains the token.
If the item does not exist, Rust creates it from `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` once.
Rust encrypts the token before it commits the item. Later starts ignore these environment variables.
The token enters JavaScript only through an operation. No startup script, transcript, or world file stores it.
The console selects `openai/gpt-6.1-sol` with low reasoning effort.
This model rejects disabled reasoning. Tests use the faux provider without credentials.
The harness reads configured providers from `/etc/sausage/config.yaml` through Rust.
`models.ts` registers pi-ai adapters for Anthropic Messages, OpenAI Responses, and OpenAI-compatible Chat Completions.
Provider API keys in the config are intentionally visible to agents. Rust redacts them from module errors.
The host refreshes models.dev metadata at startup, with a private cache and a packaged snapshot as offline fallbacks.
Metadata supplies context limits, reasoning support, and prices. Pi-ai stores estimated costs in assistant usage.
Provider and Markdown roster edits apply at the next run boundary.
The console targets the configured main agent conversation. A rename retains its conversation.
Tests use the faux provider or local fake servers without real credentials.
Credential requests own background pi-durable tasks in their requesting conversations.
Filed notifications create tasks. Startup reconciliation also creates missing tasks, including requests decided during a host stop.
@@ -85,5 +86,5 @@ The signal client sends `Input::Stop`. The harness closes its watches, credentia
The host then stops its process services and HTTP listener.
Console output still streams assistant messages, without replay of the historical transcript.
`harness_foundation.rs` runs the three Standing agent input tests and the scheduler phase-boundary test through bundled V8 modules.
`harness_foundation.rs` runs the three Main agent input tests and the scheduler phase-boundary test through bundled V8 modules.
It also tests the channel with closed stdin, CLI EOF and SIGTERM, submission scans, and the patched bundle hooks.
+10 -10
View File
@@ -1,4 +1,4 @@
/** All 19 Standing agent pause cases, executed against world snapshot modules. */
/** All 19 Main agent pause cases, executed against world snapshot modules. */
import {expect,it,until,setImmediate} from "./automations-test-support";
import {fixture} from "./automations-pause-fixture";
import {BACKGROUND_CONTEXT as context} from "@earendil-works/chord/context";
@@ -7,7 +7,7 @@ import {GenerationTask,hook,LiveDoc,ROOT_CONVERSATION_ID,defineExtension} from "
import type {Harness,ConversationId} from "@earendil-works/pi-durable";
const {actions,autoPlay,passive,userActions}=await import(`${globalThis.automationBase}/pause/server.ts`);
const {lasted}=await import(`${globalThis.automationBase}/pause/lasted.ts`);
const {Standing}=await import(`${globalThis.automationBase}/subagents/api.ts`);
const {MainAgent}=await import(`${globalThis.automationBase}/subagents/api.ts`);
const generations = async (harness: Harness) =>
(await harness.inspect(context)).tasks.filter(({ record }) => record.kind === 'pi.generation')
@@ -245,9 +245,9 @@ it('pauses on the first rate-limited answer: the run waits in its retry, fails n
},
])
const { root, harness } = await f.open()
const standing = await harness.createConversation({ ownership: { kind: 'ownerless' } }, context)
const mainAgent = await harness.createConversation({ ownership: { kind: 'ownerless' } }, context)
await harness.commit(async (tx) => {
;(await tx.doc(Standing, ROOT_CONVERSATION_ID))['$standing'] = standing.id
;(await tx.doc(MainAgent, ROOT_CONVERSATION_ID))['$main-agent'] = mainAgent.id
}, context)
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(async () => (await f.action('state')).limit !== undefined)
@@ -263,7 +263,7 @@ it('pauses on the first rate-limited answer: the run waits in its retry, fails n
expect(generation.record.state.status).not.toBe('terminal')
expect((await harness.snapshot(LiveDoc, root.id, context))?.run).toBeDefined()
const cards = async () =>
(await standing.context(context)).entries.filter((entry) => entry.kind === 'pause.limit')
(await mainAgent.context(context)).entries.filter((entry) => entry.kind === 'pause.limit')
await until(async () => (await cards()).length === 1)
expect((await cards())[0].data).toMatchObject({ error: limit, conversationId: root.id })
@@ -420,9 +420,9 @@ it('plays a rate-limit pause by itself once, N minutes in, only when the user sw
},
])
const { root, harness } = await f.open()
const standing = await harness.createConversation({ ownership: { kind: 'ownerless' } }, context)
const mainAgent = await harness.createConversation({ ownership: { kind: 'ownerless' } }, context)
await harness.commit(async (tx) => {
;(await tx.doc(Standing, ROOT_CONVERSATION_ID))['$standing'] = standing.id
;(await tx.doc(MainAgent, ROOT_CONVERSATION_ID))['$main-agent'] = mainAgent.id
}, context)
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(async () => (await f.action('state')).limit !== undefined)
@@ -443,14 +443,14 @@ it('plays a rate-limit pause by itself once, N minutes in, only when the user sw
expect(responses).toBe(2)
// Once: the pause is over, so a second due timer finds nothing to play.
expect(await f.autoPlay(since! + 31 * 60_000)).toBe(false)
const played = (await standing.context(context)).entries.filter(
const played = (await mainAgent.context(context)).entries.filter(
(entry) => entry.kind === 'pause.autoplay',
)
expect(played).toHaveLength(1)
expect(played[0].data).toMatchObject({ since, at: since! + 30 * 60_000, minutes: 30 })
const notes = (await root.context(context)).entries.filter((entry) => entry.kind === 'pause.note')
expect(notes[0].data).toMatchObject({ auto: true })
expect(notes[0].model?.[0]?.content).toMatch(/continued after Standing agent played by itself\.$/)
expect(notes[0].model?.[0]?.content).toMatch(/continued after Main agent played by itself\.$/)
// The setting stays for the next limit.
expect((await f.action('state')).autoPlay).toBe(30)
})
@@ -495,7 +495,7 @@ const at = (kind: string, phase: string) => ({ kind, state: { checkpoint: { phas
it('treats message deliveries and routes as passive waiters', () => {
expect(passive(at('board.delivery', 'deliver'))).toBe(true)
expect(passive(at('board.delivery', 'reply'))).toBe(true)
expect(passive(at('standing.subagent-reporter', 'deliver'))).toBe(true)
expect(passive(at('main-agent.subagent-reporter', 'deliver'))).toBe(true)
expect(passive(at('board.route', 'observe'))).toBe(true)
expect(passive(at('conversations.summary', 'generate'))).toBe(false)
})
+1 -1
View File
@@ -1,4 +1,4 @@
/** Pause cases use snapshot modules and isolated durable storage, as Standing agent's fixture does. */
/** Pause cases use snapshot modules and isolated durable storage, as Main agent's fixture does. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { obj } from '@earendil-works/pi-schema'
import { createModels } from '@earendil-works/pi-ai/models'
+1 -1
View File
@@ -1,4 +1,4 @@
/** The five Standing agent calendar cases execute inside deno_core's V8. */
/** The five Main agent calendar cases execute inside deno_core's V8. */
import {describe, expect, it} from "./automations-test-support";
const {describeSchedule,nextAt,parseSchedule}=await import(`${globalThis.automationBase}/automations/schedule.ts`);
const time = Date.parse('2026-01-05T08:00:00Z')
+13 -13
View File
@@ -40128,10 +40128,10 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), async standing() {
return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() {
if (!providers.has("models") || !providers.has("subagents")) return void 0;
const { standing: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").standing(name, context);
const { main_agent_name: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").mainAgent(name, context);
return { name, conversationId };
}, async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
@@ -40381,7 +40381,7 @@ var init_automations_schedule_cases = __esm({
// automations-pause-cases.ts
var automations_pause_cases_exports = {};
var actions, autoPlay, passive, userActions, lasted, Standing, generations, limit, at;
var actions, autoPlay, passive, userActions, lasted, MainAgent, generations, limit, at;
var init_automations_pause_cases = __esm({
async "automations-pause-cases.ts"() {
init_automations_test_support();
@@ -40391,7 +40391,7 @@ var init_automations_pause_cases = __esm({
init_dist4();
({ actions, autoPlay, passive, userActions } = await import(`${globalThis.automationBase}/pause/server.ts`));
({ lasted } = await import(`${globalThis.automationBase}/pause/lasted.ts`));
({ Standing } = await import(`${globalThis.automationBase}/subagents/api.ts`));
({ MainAgent } = await import(`${globalThis.automationBase}/subagents/api.ts`));
generations = async (harness2) => (await harness2.inspect(BACKGROUND_CONTEXT)).tasks.filter(({ record: record2 }) => record2.kind === "pi.generation");
it("lets a running tool finish, starts no generation after it while paused, and continues on play", async () => {
const f = await fixture();
@@ -40604,10 +40604,10 @@ var init_automations_pause_cases = __esm({
}
]);
const { root: root2, harness: harness2 } = await f.open();
const standing = await harness2.createConversation({ ownership: { kind: "ownerless" } }, BACKGROUND_CONTEXT);
const mainAgent = await harness2.createConversation({ ownership: { kind: "ownerless" } }, BACKGROUND_CONTEXT);
await harness2.commit(async (tx) => {
;
(await tx.doc(Standing, ROOT_CONVERSATION_ID))["$standing"] = standing.id;
(await tx.doc(MainAgent, ROOT_CONVERSATION_ID))["$main-agent"] = mainAgent.id;
}, BACKGROUND_CONTEXT);
const submission2 = await root2.submit({ type: "input", content: "go" }, BACKGROUND_CONTEXT);
await until2(async () => (await f.action("state")).limit !== void 0);
@@ -40621,7 +40621,7 @@ var init_automations_pause_cases = __esm({
const [generation] = await generations(harness2);
expect(generation.record.state.status).not.toBe("terminal");
expect((await harness2.snapshot(LiveDoc, root2.id, BACKGROUND_CONTEXT))?.run).toBeDefined();
const cards = async () => (await standing.context(BACKGROUND_CONTEXT)).entries.filter((entry2) => entry2.kind === "pause.limit");
const cards = async () => (await mainAgent.context(BACKGROUND_CONTEXT)).entries.filter((entry2) => entry2.kind === "pause.limit");
await until2(async () => (await cards()).length === 1);
expect((await cards())[0].data).toMatchObject({ error: limit, conversationId: root2.id });
const played = await f.action("play");
@@ -40762,10 +40762,10 @@ var init_automations_pause_cases = __esm({
}
]);
const { root: root2, harness: harness2 } = await f.open();
const standing = await harness2.createConversation({ ownership: { kind: "ownerless" } }, BACKGROUND_CONTEXT);
const mainAgent = await harness2.createConversation({ ownership: { kind: "ownerless" } }, BACKGROUND_CONTEXT);
await harness2.commit(async (tx) => {
;
(await tx.doc(Standing, ROOT_CONVERSATION_ID))["$standing"] = standing.id;
(await tx.doc(MainAgent, ROOT_CONVERSATION_ID))["$main-agent"] = mainAgent.id;
}, BACKGROUND_CONTEXT);
const submission2 = await root2.submit({ type: "input", content: "go" }, BACKGROUND_CONTEXT);
await until2(async () => (await f.action("state")).limit !== void 0);
@@ -40782,14 +40782,14 @@ var init_automations_pause_cases = __esm({
expect((await submission2.wait(BACKGROUND_CONTEXT)).status).toBe("done");
expect(responses).toBe(2);
expect(await f.autoPlay(since + 31 * 6e4)).toBe(false);
const played = (await standing.context(BACKGROUND_CONTEXT)).entries.filter(
const played = (await mainAgent.context(BACKGROUND_CONTEXT)).entries.filter(
(entry2) => entry2.kind === "pause.autoplay"
);
expect(played).toHaveLength(1);
expect(played[0].data).toMatchObject({ since, at: since + 30 * 6e4, minutes: 30 });
const notes = (await root2.context(BACKGROUND_CONTEXT)).entries.filter((entry2) => entry2.kind === "pause.note");
expect(notes[0].data).toMatchObject({ auto: true });
expect(notes[0].model?.[0]?.content).toMatch(/continued after Standing agent played by itself\.$/);
expect(notes[0].model?.[0]?.content).toMatch(/continued after Main agent played by itself\.$/);
expect((await f.action("state")).autoPlay).toBe(30);
});
it("never plays a pause the user pressed, also when a rate limit joins it", async () => {
@@ -40828,7 +40828,7 @@ var init_automations_pause_cases = __esm({
it("treats message deliveries and routes as passive waiters", () => {
expect(passive(at("board.delivery", "deliver"))).toBe(true);
expect(passive(at("board.delivery", "reply"))).toBe(true);
expect(passive(at("standing.subagent-reporter", "deliver"))).toBe(true);
expect(passive(at("main-agent.subagent-reporter", "deliver"))).toBe(true);
expect(passive(at("board.route", "observe"))).toBe(true);
expect(passive(at("conversations.summary", "generate"))).toBe(false);
});
+1 -1
View File
@@ -1,4 +1,4 @@
/** Minimal assertions for the imported Standing agent cases, without Node or Vitest. */
/** Minimal assertions for the imported Main agent cases, without Node or Vitest. */
export const cases: {name:string;run:()=>any}[]=[]
export const it=(name:string,run:()=>any)=>cases.push({name,run})
export const describe=(_name:string,run:()=>any)=>run()
+21354 -20616
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+6 -18
View File
@@ -5,13 +5,9 @@ import { fauxProvider, fauxAssistantMessage } from '@earendil-works/pi-ai/provid
import { Harness, AgentDoc, createRegistry } from '@earendil-works/pi-durable'
import { openStorage } from './sqlite'
import { loadExtensions, fs } from './extensions'
import { parse } from 'yaml'
import {readTestConfig,writeTestConfig} from './roster-fixture'
const assert = (ok:any,message:string)=>{if(!ok)throw new Error(message);Deno.core.ops.op_output(`ok - ${message}\n`)}
const models=createModels(), faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider)
const proxyTemplate={...models.getModels('faux')[0],provider:'openai',baseUrl:'https://proxy.invalid/v1'}
models.setProvider({...faux.provider,id:'openai',getModels:()=>[proxyTemplate]})
models.setProvider({...faux.provider,id:'anthropic',getModels:()=>[{...proxyTemplate,provider:'anthropic',baseUrl:'https://proxy.invalid/apis/anthropic'}]})
const proxyAuth=models.getProvider('openai').auth
const storage=await openStorage(globalThis.storagePath), registry=createRegistry()
const harness=await Harness.open(storage,{models,registry,settings:{retry:{enabled:false},get extensions(){return registry.snapshot().installed()}}},context)
await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}})
@@ -19,10 +15,10 @@ const loader=await loadExtensions(harness,registry,storage,models,context)
try {
assert(!loader.state().error,`loader: ${loader.state().error ?? 'loaded'}`)
const {selection,conversations,State,metadata}=globalThis.core
const config = parse(await fs.read('/etc/sausage/config.yaml'))
config.standing='worker'
const config = await readTestConfig(fs)
config.main_agent_name='worker'
config.roster=[{name:'worker',description:'Worker',model:'faux/faux-1',thinking:'low',tools:['read','write'],prompt:'Worker body'}]
const save = ()=>fs.write('/etc/sausage/config.yaml',JSON.stringify(config))
const save = ()=>writeTestConfig(fs,config)
await save()
const worker=await selection.start({agent:'worker',cwd:'/project/sub'},context)
let agent=await worker.agent(context)
@@ -77,19 +73,11 @@ try {
faux.setResponses([fauxAssistantMessage('second')])
await (await worker.submit({type:'input',content:'second'},context)).wait(context)
assert((await harness.snapshot(AgentDoc,worker.id,context)).model.modelId==='second','edited roster changes next model without restart')
config.roster[0].model='openai/new';await save()
faux.setResponses([fauxAssistantMessage('proxy')])
await (await worker.submit({type:'input',content:'proxy'},context)).wait(context)
assert(models.getModel('openai','new').baseUrl===proxyTemplate.baseUrl && models.getModel('openai','new').cost.input===0 && models.getProvider('openai').auth===proxyAuth,'new model retains proxy transport and auth')
config.roster[0].model='anthropic/new';await save()
faux.setResponses([fauxAssistantMessage('anthropic')])
await (await worker.submit({type:'input',content:'anthropic'},context)).wait(context)
assert(models.getModel('anthropic','new').baseUrl==='https://proxy.invalid/apis/anthropic','new Anthropic model retains its own provider transport')
globalThis.modelPin={provider:'faux',modelId:'faux-1'}
faux.setResponses([fauxAssistantMessage('pin')])
await (await worker.submit({type:'input',content:'pin'},context)).wait(context)
assert((await worker.agent(context)).model.provider==='faux','models.pin overrides roster selection')
await harness.commit(tx=>conversations.setStanding(tx,worker.id,true),context)
await harness.commit(tx=>conversations.setMainAgent(tx,worker.id,true),context)
let refused=false;try{await harness.commit(tx=>conversations.concludeIn(tx,[worker.id]),context)}catch{refused=true}
assert(refused,'standing conversation cannot conclude')
assert(refused,'mainAgent conversation cannot conclude')
} finally {await loader.close();await harness.close(context)}
+4 -4
View File
@@ -37396,10 +37396,10 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), async standing() {
return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() {
if (!providers.has("models") || !providers.has("subagents")) return void 0;
const { standing: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").standing(name, context);
const { main_agent_name: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").mainAgent(name, context);
return { name, conversationId };
}, async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
@@ -38403,7 +38403,7 @@ var { Automations } = await import(`${base}/automations/server.ts`);
var { ContextUsageDoc } = await import(`${base}/automations/triggers.ts`);
var { Fired } = await import(`${base}/automations/refs.ts`);
var { Messages } = await import(`${base}/messages/api.ts`);
var primary = await harness.conversation(await globalThis.dreamSubagents.standing("standing", BACKGROUND_CONTEXT), BACKGROUND_CONTEXT);
var primary = await harness.conversation(await globalThis.dreamSubagents.mainAgent("mainAgent", BACKGROUND_CONTEXT), BACKGROUND_CONTEXT);
var entries2 = async () => (await primary.context(BACKGROUND_CONTEXT)).entries;
var ledger = async () => (await harness.snapshot(Automations, BACKGROUND_CONTEXT))?.automations ?? {};
var requested = async () => (await entries2()).filter((e) => e.kind === "dream.requested");
+1 -1
View File
@@ -35,7 +35,7 @@ const {Automations}=await import(`${base}/automations/server.ts`)
const {ContextUsageDoc}=await import(`${base}/automations/triggers.ts`)
const {Fired}=await import(`${base}/automations/refs.ts`)
const {Messages}=await import(`${base}/messages/api.ts`)
const primary=(await harness.conversation(await globalThis.dreamSubagents.standing('standing',context),context))!
const primary=(await harness.conversation(await globalThis.dreamSubagents.mainAgent('mainAgent',context),context))!
const entries=async()=> (await primary.context(context)).entries
const ledger=async()=> (await harness.snapshot(Automations,context))?.automations??{}
const requested=async()=> (await entries()).filter((e:any)=>e.kind==='dream.requested')
+1 -1
View File
@@ -20533,7 +20533,7 @@ registry.install(CredentialRequests);
var harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry }, BACKGROUND_CONTEXT);
var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "faux", modelId: "faux-1" }, extensions: [CredentialRequests] } });
await (await root.submit({ type: "input", content: "initial", requestId: "initial" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
if (globalThis.proxyOnly) {
if (globalThis.providerOnly) {
await harness.close(BACKGROUND_CONTEXT);
} else {
await Deno.core.ops.op_file("write", "/conversation", Array.from(new TextEncoder().encode(root.id)));
+1 -1
View File
@@ -14,7 +14,7 @@ registry.install(CredentialRequests);
const harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry }, context);
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, extensions: [CredentialRequests] } });
await (await root.submit({ type: 'input', content: 'initial', requestId: 'initial' }, context)).wait(context);
if (globalThis.proxyOnly) {
if (globalThis.providerOnly) {
await harness.close(context);
} else {
await Deno.core.ops.op_file('write', '/conversation', Array.from(new TextEncoder().encode(root.id)));
+3 -3
View File
@@ -38336,10 +38336,10 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), async standing() {
return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() {
if (!providers.has("models") || !providers.has("subagents")) return void 0;
const { standing: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").standing(name, context);
const { main_agent_name: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").mainAgent(name, context);
return { name, conversationId };
}, async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
+1 -1
View File
@@ -42,7 +42,7 @@ Event emission updates the feed inside the same transaction.
Notices become `sausage.notice` events.
The host records each distinct notice once per facet load.
This task does not add Standing agent's build or supervisor startup events.
This task does not add Main agent's build or supervisor startup events.
## Tests
+3 -3
View File
@@ -38336,10 +38336,10 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), async standing() {
return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() {
if (!providers.has("models") || !providers.has("subagents")) return void 0;
const { standing: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").standing(name, context);
const { main_agent_name: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").mainAgent(name, context);
return { name, conversationId };
}, async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
+3 -3
View File
@@ -210,10 +210,10 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`,`${repo}/.git/packed-refs`,`${repo}/settings.json`]);
await enqueue();
const pending = (async()=> {while(active){await ops.op_watch_next(rid);if(active)await enqueue();}})();
return {reload:enqueue, state:()=>({...state}), async standing() {
return {reload:enqueue, state:()=>({...state}), async mainAgent() {
if (!providers.has('models') || !providers.has('subagents')) return undefined;
const {standing:name}=await providers.get('models').discover(repo);
const conversationId=await providers.get('subagents').standing(name,context);
const {main_agent_name:name}=await providers.get('models').discover(repo);
const conversationId=await providers.get('subagents').mainAgent(name,context);
return {name,conversationId};
}, async invoke(name:string,key:string,args:any,signal:AbortSignal,conversationId?:number) {
const facet = facets.find(f=>f.extension.name===name);
+1 -1
View File
@@ -1,4 +1,4 @@
// Standing agent's input-placement cases, scheduler hold, and the headless channel contract.
// Main agent's input-placement cases, scheduler hold, and the headless channel contract.
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { createModels } from '@earendil-works/pi-ai/models';
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
+35 -35
View File
@@ -1,4 +1,4 @@
/** The seven Standing agent goal cases use overlaid documents and the loaded server modules. */
/** The seven Main agent goal cases use overlaid documents and the loaded server modules. */
import type { ConversationId, GenerationHooks, Tx } from '@earendil-works/pi-durable'
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context'
import { expect, it } from './goal-questions-test-support'
@@ -6,7 +6,7 @@ const { reminderTask } = await import(`${globalThis.goalBase}/goal/reminder.ts`)
const {default:goalExtension,act,actions,end,Goals,openGoal,start,TRIES} = await import(`${globalThis.goalBase}/goal/server.ts`)
import type { Host } from './extensions.ts'
const STANDING = 5 as ConversationId
const MAIN_AGENT = 5 as ConversationId
const screenshot = { type: 'image', data: 'aGk=', mimeType: 'image/png' }
/** Like the harness's document overlay, a proxy at every level: structuredClone refuses it. */
@@ -18,17 +18,17 @@ const overlay = <T extends object>(value: T): T =>
},
})
/** A transaction over overlaid documents and a few entries of @standing's conversation; @standing is conversation 5. */
/** A transaction over overlaid documents and a few entries of @mainAgent's conversation; @mainAgent is conversation 5. */
function fakeTx(
newer: { id: number; conversationId: number; kind?: string; model: object[] }[] = [],
) {
const docs = new Map<string, object>()
const entries = [
...newer,
{ id: 30, conversationId: STANDING, model: [{ role: 'user', content: 'no images' }] },
{ id: 30, conversationId: MAIN_AGENT, model: [{ role: 'user', content: 'no images' }] },
{
id: 20,
conversationId: STANDING,
conversationId: MAIN_AGENT,
model: [{ role: 'toolResult', content: [{ type: 'text', text: 'shot' }, screenshot] }],
},
{ id: 10, conversationId: 9, model: [{ role: 'user', content: [screenshot] }] },
@@ -47,7 +47,7 @@ function fakeTx(
if (!docs.has(key))
docs.set(
key,
def.kind === 'standing.standing-agents' ? { '$standing': STANDING } : def.initial(),
def.kind === 'main-agent.index' ? { '$main-agent': MAIN_AGENT } : def.initial(),
)
return overlay(docs.get(key)!)
},
@@ -120,27 +120,27 @@ it('keeps one goal open at a time and ends it once', async () => {
it('blocks a criterion after repeated failed tries and finishes with the outcome', async () => {
const tx = fakeTx()
await expect(act(tx, STANDING, { action: 'met', n: 1 }, 0)).rejects.toThrow('No hands-off goal')
await expect(act(tx, MAIN_AGENT, { action: 'met', n: 1 }, 0)).rejects.toThrow('No hands-off goal')
await start(tx, 'Goal', 0)
await act(tx, STANDING, { action: 'criteria', criteria: ['Loads in 1s', 'Tests pass'] }, 0)
await expect(act(tx, STANDING, { action: 'met', n: 3 }, 0)).rejects.toThrow('No criterion 3')
for (let i = 1; i < TRIES; i++) await act(tx, STANDING, { action: 'failed', n: 1 }, 0)
await act(tx, MAIN_AGENT, { action: 'criteria', criteria: ['Loads in 1s', 'Tests pass'] }, 0)
await expect(act(tx, MAIN_AGENT, { action: 'met', n: 3 }, 0)).rejects.toThrow('No criterion 3')
for (let i = 1; i < TRIES; i++) await act(tx, MAIN_AGENT, { action: 'failed', n: 1 }, 0)
expect((await openGoal(tx))!.criteria[0]).toMatchObject({ status: 'open', tries: TRIES - 1 })
const last = await act(tx, STANDING, { action: 'failed', n: 1 }, 0)
const last = await act(tx, MAIN_AGENT, { action: 'failed', n: 1 }, 0)
expect(last.answer).toContain('blocked')
expect((await openGoal(tx))!.criteria[0].status).toBe('blocked')
// Rewording keeps what was learned about an unchanged criterion.
await act(tx, STANDING, { action: 'criteria', criteria: ['Loads in 1s', 'Lint passes'] }, 0)
await act(tx, MAIN_AGENT, { action: 'criteria', criteria: ['Loads in 1s', 'Lint passes'] }, 0)
expect((await openGoal(tx))!.criteria.map((item) => item.status)).toEqual(['blocked', 'open'])
await expect(act(tx, STANDING, { action: 'finish', summary: 'x' }, 0)).rejects.toThrow('outcome')
await expect(act(tx, MAIN_AGENT, { action: 'finish', summary: 'x' }, 0)).rejects.toThrow('outcome')
const done = await act(
tx,
STANDING,
MAIN_AGENT,
{ action: 'finish', outcome: 'blocked', summary: 'Stuck' },
4,
)
expect(done.ended).toMatchObject({
ended: { by: 'standing', outcome: 'blocked', summary: 'Stuck' },
ended: { by: 'mainAgent', outcome: 'blocked', summary: 'Stuck' },
})
expect(await openGoal(tx)).toBeUndefined()
})
@@ -149,11 +149,11 @@ it('records decisions with the screenshots of its own conversation only', async
const tx = fakeTx()
await start(tx, 'Goal', 0)
await expect(
act(tx, STANDING, { action: 'decide', question: 'Go?', picked: 'Yes' }, 0),
act(tx, MAIN_AGENT, { action: 'decide', question: 'Go?', picked: 'Yes' }, 0),
).rejects.toThrow('reason')
await act(
tx,
STANDING,
MAIN_AGENT,
{
action: 'decide',
question: 'Approve?',
@@ -165,7 +165,7 @@ it('records decisions with the screenshots of its own conversation only', async
1,
)
await expect(
act(tx, STANDING, { action: 'decide', question: 'q', picked: 'p', reason: 'r', images: 10 }, 2),
act(tx, MAIN_AGENT, { action: 'decide', question: 'q', picked: 'p', reason: 'r', images: 10 }, 2),
).rejects.toThrow('has no images')
expect((await openGoal(tx))!.decisions).toEqual([
{ at: 1, question: 'Approve?', picked: 'Yes', reason: 'Meets 1', conversation: 7, images: 20 },
@@ -173,17 +173,17 @@ it('records decisions with the screenshots of its own conversation only', async
})
it('never takes an earlier turn\'s screenshot for images: "latest"', async () => {
const tx = fakeTx([{ id: 40, kind: 'pi.user', conversationId: STANDING, model: [] }])
const tx = fakeTx([{ id: 40, kind: 'pi.user', conversationId: MAIN_AGENT, model: [] }])
await start(tx, 'Goal', 0)
const decide = { action: 'decide', question: 'q', picked: 'p', reason: 'r' } as const
await expect(act(tx, STANDING, { ...decide, images: 'latest' }, 1)).rejects.toThrow(
await expect(act(tx, MAIN_AGENT, { ...decide, images: 'latest' }, 1)).rejects.toThrow(
'no images in this turn',
)
await act(tx, STANDING, { ...decide, images: 20 }, 2)
await act(tx, MAIN_AGENT, { ...decide, images: 20 }, 2)
expect((await openGoal(tx))!.decisions).toMatchObject([{ images: 20 }])
})
it("notes the open goal on @standing's runs only, and lets only @standing keep it", async () => {
it("notes the open goal on @mainAgent's runs only, and lets only @mainAgent keep it", async () => {
const tx = fakeTx()
const extension = goalExtension({
doc: () => undefined,
@@ -193,10 +193,10 @@ it("notes the open goal on @standing's runs only, and lets only @standing keep i
const hooks = extension.hooks![0].handlers as GenerationHooks
const before = (conversationId: number, kind = 'pi.user') =>
hooks.beforeInput({ kind }, { tx, conversationId } as never, BACKGROUND_CONTEXT)
expect(await before(STANDING)).toBeUndefined()
expect(await before(MAIN_AGENT)).toBeUndefined()
await start(tx, 'Make it fast', 0)
expect(await before(9)).toBeUndefined()
const note = (await before(STANDING))!.entries![0]
const note = (await before(MAIN_AGENT))!.entries![0]
expect(note.kind).toBe('goal.note')
expect(note.model![0].content).toContain(
'Hands-off goal 1, granted by the user, who is away: Make it fast',
@@ -209,19 +209,19 @@ it("notes the open goal on @standing's runs only, and lets only @standing keep i
{ conversationId, commit: (fn: (tx: Tx) => unknown) => fn(tx) } as never,
undefined as never,
)
await expect(run(9, { action: 'met', n: 1 })).rejects.toThrow('Only the standing agent')
expect(await run(STANDING, { action: 'criteria', criteria: ['Fast'] })).toEqual({
await expect(run(9, { action: 'met', n: 1 })).rejects.toThrow('Only the main agent')
expect(await run(MAIN_AGENT, { action: 'criteria', criteria: ['Fast'] })).toEqual({
content: [{ type: 'text', text: 'Goal 1 has 1 criteria.' }],
})
expect((await tx.doc(Goals, 1 as ConversationId)).goals[0].criteria).toHaveLength(1)
// Finishing ends the goal and creates the report's delivery in the same commit; the note stops.
await run(STANDING, { action: 'finish', outcome: 'met', summary: 'Done' })
await run(MAIN_AGENT, { action: 'finish', outcome: 'met', summary: 'Done' })
expect(tx.tasks).toEqual([
{ name: 'goal.deliver', input: expect.objectContaining({ to: STANDING, kind: 'goal.report' }) },
{ name: 'goal.deliver', input: expect.objectContaining({ to: MAIN_AGENT, kind: 'goal.report' }) },
{ name: 'goal.reminder', input: { goal: 1 } },
])
expect(await before(STANDING)).toBeUndefined()
expect(await before(MAIN_AGENT)).toBeUndefined()
})
it('keeps a reminder while the goal is open: paused while waiting on the user, gone once ended', async () => {
@@ -232,7 +232,7 @@ it('keeps a reminder while the goal is open: paused while waiting on the user, g
const run = (args: object) =>
tool.execute(
args as never,
{ conversationId: STANDING, commit: (fn: (tx: Tx) => unknown) => fn(tx) } as never,
{ conversationId: MAIN_AGENT, commit: (fn: (tx: Tx) => unknown) => fn(tx) } as never,
undefined as never,
)
await actions.start({ text: 'Make it fast' }, { host } as never)
@@ -240,7 +240,7 @@ it('keeps a reminder while the goal is open: paused while waiting on the user, g
// its key instead of making a second one, and pauses it.
await service.create({
name: 'Goal reminder · goal 1',
subscribers: ['@standing'],
subscribers: ['@mainAgent'],
key: 'goal.reminder:1',
})
;(await openGoal(tx))!.waiting = { reason: 'Which account?', at: 0 }
@@ -249,7 +249,7 @@ it('keeps a reminder while the goal is open: paused while waiting on the user, g
{
id: 'A1',
name: 'Goal reminder · goal 1',
subscribers: ['@standing'],
subscribers: ['@mainAgent'],
key: 'goal.reminder:1',
enabled: false,
},
@@ -261,10 +261,10 @@ it('keeps a reminder while the goal is open: paused while waiting on the user, g
// The user renamed it: it stays linked by its id.
ledger.get('A1')!.name = 'Night check'
// Its fire tells @standing what to check; other inputs do not.
// Its fire tells @mainAgent what to check; other inputs do not.
const hooks = extension.hooks![0].handlers as GenerationHooks
const note = async (input: object) =>
(await hooks.beforeInput(input as never, { tx, conversationId: STANDING }, BACKGROUND_CONTEXT))!
(await hooks.beforeInput(input as never, { tx, conversationId: MAIN_AGENT }, BACKGROUND_CONTEXT))!
.entries![0].model![0].content as string
expect(await note({ kind: 'automations.fired', data: { id: 'A1' } })).toContain(
'This is the goal reminder',
@@ -315,7 +315,7 @@ it('grants nothing through a start card still queued after the user ended its go
expect(
await hooks.beforeInput(
{ kind: 'goal.started' },
{ tx, conversationId: STANDING },
{ tx, conversationId: MAIN_AGENT },
BACKGROUND_CONTEXT,
),
).toBeUndefined()
+3 -3
View File
@@ -17,7 +17,7 @@ export async function fixture(clock: any = {}) {
const base=`file:///run/sausage/extensions/${loader.state().good}/extensions`
const question=await import(/* @vite-ignore */ `${base}/questions/server.ts`)
const {Messages}=await import(/* @vite-ignore */ `${base}/messages/api.ts`)
const {Standing}=await import(/* @vite-ignore */ `${base}/subagents/api.ts`)
const {MainAgent}=await import(/* @vite-ignore */ `${base}/subagents/api.ts`)
const observed:string[]=[];let heard:any
const route=async(transcript:any)=>{
faux.appendResponses([route]);const last=transcript.messages.at(-1),text=typeof last.content==='string'?last.content:last.content.filter((p:any)=>p.type==='text').map((p:any)=>p.text).join('\n')
@@ -30,8 +30,8 @@ export async function fixture(clock: any = {}) {
const settled=async()=>{await until(async()=>(await harness.commit((tx:any)=>tx.scanTasks({},1000),context)).items.every((t:any)=>t.state.status==='terminal'));await harness.waitForIdle(context)}
const entries=async(id:number)=>(await harness.commit((tx:any)=>tx.scanEntries({conversationId:id},1000),context)).items.toSorted((a:any,b:any)=>a.id-b.id)
const type=async(id:number,content:string)=>{const c=await harness.conversation(id,context);await(await c.submit({type:'input',content},context)).wait(context)}
const standing=async()=>{const c=await harness.createConversation({ownership:{kind:'ownerless'}},context);await c.configure({model:{provider:'faux',modelId:'faux-1'}},context);await harness.commit(async(tx:any)=>{(await tx.doc(Standing,root.id))['$standing']=c.id},context);return c.id}
const mainAgent=async()=>{const c=await harness.createConversation({ownership:{kind:'ownerless'}},context);await c.configure({model:{provider:'faux',modelId:'faux-1'}},context);await harness.commit(async(tx:any)=>{(await tx.doc(MainAgent,root.id))['$main-agent']=c.id},context);return c.id}
const ask=async(id:number,args:any)=>{await type(id,`SEND ${JSON.stringify({to:'user',...args})}`);await settled();const result=(await entries(id)).findLast((e:any)=>e.kind==='pi.tool-result')?.model?.[0];return {error:result?.isError?result.content.map((p:any)=>p.text).join(''):undefined,message:(await log()).messages.findLast((m:any)=>m.from===id&&m.to==='user')}}
const action=(conversationId?:number)=>({host:globalThis.testHost,conversationId,signal:new AbortController().signal})
return {base,faux,loader,host:globalThis.testHost,opened:{harness,root,close:async()=>{await loader.close();await harness.close(context);await storage.close()}},standing,type,ask,answer:(args:any)=>question.actions.answer(args,action()),dismiss:(messageId:number)=>question.actions.dismiss({messageId},action()),log,entries,observed,settled,hear:(fn:any)=>heard=fn}
return {base,faux,loader,host:globalThis.testHost,opened:{harness,root,close:async()=>{await loader.close();await harness.close(context);await storage.close()}},mainAgent,type,ask,answer:(args:any)=>question.actions.answer(args,action()),dismiss:(messageId:number)=>question.actions.dismiss({messageId},action()),log,entries,observed,settled,hear:(fn:any)=>heard=fn}
}
+48 -48
View File
@@ -40128,10 +40128,10 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), async standing() {
return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() {
if (!providers.has("models") || !providers.has("subagents")) return void 0;
const { standing: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").standing(name, context);
const { main_agent_name: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").mainAgent(name, context);
return { name, conversationId };
}, async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
@@ -40260,7 +40260,7 @@ async function fixture(clock = {}) {
/* @vite-ignore */
`${base}/messages/api.ts`
);
const { Standing } = await import(
const { MainAgent } = await import(
/* @vite-ignore */
`${base}/subagents/api.ts`
);
@@ -40285,11 +40285,11 @@ async function fixture(clock = {}) {
const c = await harness.conversation(id, BACKGROUND_CONTEXT);
await (await c.submit({ type: "input", content }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
};
const standing = async () => {
const mainAgent = async () => {
const c = await harness.createConversation({ ownership: { kind: "ownerless" } }, BACKGROUND_CONTEXT);
await c.configure({ model: { provider: "faux", modelId: "faux-1" } }, BACKGROUND_CONTEXT);
await harness.commit(async (tx) => {
(await tx.doc(Standing, root.id))["$standing"] = c.id;
(await tx.doc(MainAgent, root.id))["$main-agent"] = c.id;
}, BACKGROUND_CONTEXT);
return c.id;
};
@@ -40304,7 +40304,7 @@ async function fixture(clock = {}) {
await loader.close();
await harness.close(BACKGROUND_CONTEXT);
await storage.close();
} }, standing, type, ask, answer: (args) => question.actions.answer(args, action()), dismiss: (messageId) => question.actions.dismiss({ messageId }, action()), log, entries: entries2, observed, settled, hear: (fn) => heard = fn };
} }, mainAgent, type, ask, answer: (args) => question.actions.answer(args, action()), dismiss: (messageId) => question.actions.dismiss({ messageId }, action()), log, entries: entries2, observed, settled, hear: (fn) => heard = fn };
}
var next;
var init_goal_questions_fixture = __esm({
@@ -40326,10 +40326,10 @@ function fakeTx(newer = []) {
const docs = /* @__PURE__ */ new Map();
const entries2 = [
...newer,
{ id: 30, conversationId: STANDING, model: [{ role: "user", content: "no images" }] },
{ id: 30, conversationId: MAIN_AGENT, model: [{ role: "user", content: "no images" }] },
{
id: 20,
conversationId: STANDING,
conversationId: MAIN_AGENT,
model: [{ role: "toolResult", content: [{ type: "text", text: "shot" }, screenshot] }]
},
{ id: 10, conversationId: 9, model: [{ role: "user", content: [screenshot] }] }
@@ -40345,7 +40345,7 @@ function fakeTx(newer = []) {
if (!docs.has(key))
docs.set(
key,
def.kind === "standing.standing-agents" ? { "$standing": STANDING } : def.initial()
def.kind === "main-agent.index" ? { "$main-agent": MAIN_AGENT } : def.initial()
);
return overlay(docs.get(key));
},
@@ -40390,14 +40390,14 @@ function fakeHost(tx) {
};
return { host, ledger, service, sync };
}
var reminderTask, goalExtension, act, actions, end, Goals, openGoal, start, TRIES, STANDING, screenshot, overlay;
var reminderTask, goalExtension, act, actions, end, Goals, openGoal, start, TRIES, MAIN_AGENT, screenshot, overlay;
var init_goal_cases = __esm({
async "goal-cases.ts"() {
init_context();
init_goal_questions_test_support();
({ reminderTask } = await import(`${globalThis.goalBase}/goal/reminder.ts`));
({ default: goalExtension, act, actions, end, Goals, openGoal, start, TRIES } = await import(`${globalThis.goalBase}/goal/server.ts`));
STANDING = 5;
MAIN_AGENT = 5;
screenshot = { type: "image", data: "aGk=", mimeType: "image/png" };
overlay = (value2) => new Proxy(value2, {
get: (target, key) => {
@@ -40421,26 +40421,26 @@ var init_goal_cases = __esm({
});
it("blocks a criterion after repeated failed tries and finishes with the outcome", async () => {
const tx = fakeTx();
await expect(act(tx, STANDING, { action: "met", n: 1 }, 0)).rejects.toThrow("No hands-off goal");
await expect(act(tx, MAIN_AGENT, { action: "met", n: 1 }, 0)).rejects.toThrow("No hands-off goal");
await start(tx, "Goal", 0);
await act(tx, STANDING, { action: "criteria", criteria: ["Loads in 1s", "Tests pass"] }, 0);
await expect(act(tx, STANDING, { action: "met", n: 3 }, 0)).rejects.toThrow("No criterion 3");
for (let i = 1; i < TRIES; i++) await act(tx, STANDING, { action: "failed", n: 1 }, 0);
await act(tx, MAIN_AGENT, { action: "criteria", criteria: ["Loads in 1s", "Tests pass"] }, 0);
await expect(act(tx, MAIN_AGENT, { action: "met", n: 3 }, 0)).rejects.toThrow("No criterion 3");
for (let i = 1; i < TRIES; i++) await act(tx, MAIN_AGENT, { action: "failed", n: 1 }, 0);
expect((await openGoal(tx)).criteria[0]).toMatchObject({ status: "open", tries: TRIES - 1 });
const last = await act(tx, STANDING, { action: "failed", n: 1 }, 0);
const last = await act(tx, MAIN_AGENT, { action: "failed", n: 1 }, 0);
expect(last.answer).toContain("blocked");
expect((await openGoal(tx)).criteria[0].status).toBe("blocked");
await act(tx, STANDING, { action: "criteria", criteria: ["Loads in 1s", "Lint passes"] }, 0);
await act(tx, MAIN_AGENT, { action: "criteria", criteria: ["Loads in 1s", "Lint passes"] }, 0);
expect((await openGoal(tx)).criteria.map((item) => item.status)).toEqual(["blocked", "open"]);
await expect(act(tx, STANDING, { action: "finish", summary: "x" }, 0)).rejects.toThrow("outcome");
await expect(act(tx, MAIN_AGENT, { action: "finish", summary: "x" }, 0)).rejects.toThrow("outcome");
const done = await act(
tx,
STANDING,
MAIN_AGENT,
{ action: "finish", outcome: "blocked", summary: "Stuck" },
4
);
expect(done.ended).toMatchObject({
ended: { by: "standing", outcome: "blocked", summary: "Stuck" }
ended: { by: "mainAgent", outcome: "blocked", summary: "Stuck" }
});
expect(await openGoal(tx)).toBeUndefined();
});
@@ -40448,11 +40448,11 @@ var init_goal_cases = __esm({
const tx = fakeTx();
await start(tx, "Goal", 0);
await expect(
act(tx, STANDING, { action: "decide", question: "Go?", picked: "Yes" }, 0)
act(tx, MAIN_AGENT, { action: "decide", question: "Go?", picked: "Yes" }, 0)
).rejects.toThrow("reason");
await act(
tx,
STANDING,
MAIN_AGENT,
{
action: "decide",
question: "Approve?",
@@ -40464,23 +40464,23 @@ var init_goal_cases = __esm({
1
);
await expect(
act(tx, STANDING, { action: "decide", question: "q", picked: "p", reason: "r", images: 10 }, 2)
act(tx, MAIN_AGENT, { action: "decide", question: "q", picked: "p", reason: "r", images: 10 }, 2)
).rejects.toThrow("has no images");
expect((await openGoal(tx)).decisions).toEqual([
{ at: 1, question: "Approve?", picked: "Yes", reason: "Meets 1", conversation: 7, images: 20 }
]);
});
it(`never takes an earlier turn's screenshot for images: "latest"`, async () => {
const tx = fakeTx([{ id: 40, kind: "pi.user", conversationId: STANDING, model: [] }]);
const tx = fakeTx([{ id: 40, kind: "pi.user", conversationId: MAIN_AGENT, model: [] }]);
await start(tx, "Goal", 0);
const decide = { action: "decide", question: "q", picked: "p", reason: "r" };
await expect(act(tx, STANDING, { ...decide, images: "latest" }, 1)).rejects.toThrow(
await expect(act(tx, MAIN_AGENT, { ...decide, images: "latest" }, 1)).rejects.toThrow(
"no images in this turn"
);
await act(tx, STANDING, { ...decide, images: 20 }, 2);
await act(tx, MAIN_AGENT, { ...decide, images: 20 }, 2);
expect((await openGoal(tx)).decisions).toMatchObject([{ images: 20 }]);
});
it("notes the open goal on @standing's runs only, and lets only @standing keep it", async () => {
it("notes the open goal on @mainAgent's runs only, and lets only @mainAgent keep it", async () => {
const tx = fakeTx();
const extension = goalExtension({
doc: () => void 0,
@@ -40489,10 +40489,10 @@ var init_goal_cases = __esm({
});
const hooks = extension.hooks[0].handlers;
const before = (conversationId, kind = "pi.user") => hooks.beforeInput({ kind }, { tx, conversationId }, BACKGROUND_CONTEXT);
expect(await before(STANDING)).toBeUndefined();
expect(await before(MAIN_AGENT)).toBeUndefined();
await start(tx, "Make it fast", 0);
expect(await before(9)).toBeUndefined();
const note = (await before(STANDING)).entries[0];
const note = (await before(MAIN_AGENT)).entries[0];
expect(note.kind).toBe("goal.note");
expect(note.model[0].content).toContain(
"Hands-off goal 1, granted by the user, who is away: Make it fast"
@@ -40503,17 +40503,17 @@ var init_goal_cases = __esm({
{ conversationId, commit: (fn) => fn(tx) },
void 0
);
await expect(run2(9, { action: "met", n: 1 })).rejects.toThrow("Only the standing agent");
expect(await run2(STANDING, { action: "criteria", criteria: ["Fast"] })).toEqual({
await expect(run2(9, { action: "met", n: 1 })).rejects.toThrow("Only the main agent");
expect(await run2(MAIN_AGENT, { action: "criteria", criteria: ["Fast"] })).toEqual({
content: [{ type: "text", text: "Goal 1 has 1 criteria." }]
});
expect((await tx.doc(Goals, 1)).goals[0].criteria).toHaveLength(1);
await run2(STANDING, { action: "finish", outcome: "met", summary: "Done" });
await run2(MAIN_AGENT, { action: "finish", outcome: "met", summary: "Done" });
expect(tx.tasks).toEqual([
{ name: "goal.deliver", input: expect.objectContaining({ to: STANDING, kind: "goal.report" }) },
{ name: "goal.deliver", input: expect.objectContaining({ to: MAIN_AGENT, kind: "goal.report" }) },
{ name: "goal.reminder", input: { goal: 1 } }
]);
expect(await before(STANDING)).toBeUndefined();
expect(await before(MAIN_AGENT)).toBeUndefined();
});
it("keeps a reminder while the goal is open: paused while waiting on the user, gone once ended", async () => {
const tx = fakeTx();
@@ -40522,13 +40522,13 @@ var init_goal_cases = __esm({
const tool = extension.tools.find((candidate) => candidate.name === "goal");
const run2 = (args) => tool.execute(
args,
{ conversationId: STANDING, commit: (fn) => fn(tx) },
{ conversationId: MAIN_AGENT, commit: (fn) => fn(tx) },
void 0
);
await actions.start({ text: "Make it fast" }, { host });
await service.create({
name: "Goal reminder \xB7 goal 1",
subscribers: ["@standing"],
subscribers: ["@mainAgent"],
key: "goal.reminder:1"
});
(await openGoal(tx)).waiting = { reason: "Which account?", at: 0 };
@@ -40537,7 +40537,7 @@ var init_goal_cases = __esm({
{
id: "A1",
name: "Goal reminder \xB7 goal 1",
subscribers: ["@standing"],
subscribers: ["@mainAgent"],
key: "goal.reminder:1",
enabled: false
}
@@ -40548,7 +40548,7 @@ var init_goal_cases = __esm({
expect(ledger.get("A1").enabled).toBe(true);
ledger.get("A1").name = "Night check";
const hooks = extension.hooks[0].handlers;
const note = async (input) => (await hooks.beforeInput(input, { tx, conversationId: STANDING }, BACKGROUND_CONTEXT)).entries[0].model[0].content;
const note = async (input) => (await hooks.beforeInput(input, { tx, conversationId: MAIN_AGENT }, BACKGROUND_CONTEXT)).entries[0].model[0].content;
expect(await note({ kind: "automations.fired", data: { id: "A1" } })).toContain(
"This is the goal reminder"
);
@@ -40591,7 +40591,7 @@ var init_goal_cases = __esm({
expect(
await hooks.beforeInput(
{ kind: "goal.started" },
{ tx, conversationId: STANDING },
{ tx, conversationId: MAIN_AGENT },
BACKGROUND_CONTEXT
)
).toBeUndefined();
@@ -40645,7 +40645,7 @@ var init_questions_cases = __esm({
describe2("questions on a message", () => {
it("are stored as the tool validated them, only on reply-expected messages to the user", async () => {
const f = await fixture2();
const id = await f.standing();
const id = await f.mainAgent();
const sent = await f.ask(id, { text: "Decide these.", questions: decisions });
expect(sent.error).toBeUndefined();
expect(sent.message).toMatchObject({ state: "delivered", questions: decisions });
@@ -40662,7 +40662,7 @@ var init_questions_cases = __esm({
});
it("refuses questions the card could not answer, naming the question", async () => {
const f = await fixture2();
const id = await f.standing();
const id = await f.mainAgent();
const refused = async (questions, reason) => {
const { error } = await f.ask(id, { text: "Decide.", questions });
expect(error).toContain(reason);
@@ -40723,7 +40723,7 @@ var init_questions_cases = __esm({
});
it("answers in the message numbering for every kind, with a note after the answers", async () => {
const f = await fixture2();
const id = await f.standing();
const id = await f.mainAgent();
const { message } = await f.ask(id, { text: "Decide these.", questions: decisions });
expect(userActions).toEqual(["answer", "dismiss"]);
await expect(
@@ -40770,7 +40770,7 @@ var init_questions_cases = __esm({
});
it("sends a note on its own, and free text for a message without questions", async () => {
const f = await fixture2();
const id = await f.standing();
const id = await f.mainAgent();
const { message } = await f.ask(id, { text: "Decide these.", questions: decisions });
await expect(f.answer({ messageId: message.id, answers: [], note: " " })).rejects.toThrow(
"Invalid answers"
@@ -40796,9 +40796,9 @@ var init_questions_cases = __esm({
"These questions are no longer open"
);
});
it("keeps @standing's open message open until its card answers it, naming it by its questions", async () => {
it("keeps @mainAgent's open message open until its card answers it, naming it by its questions", async () => {
const f = await fixture2();
const id = await f.standing();
const id = await f.mainAgent();
const { message } = await f.ask(id, { text: "Decide these.", questions: decisions });
await f.type(
f.opened.root.id,
@@ -40826,7 +40826,7 @@ var init_questions_cases = __esm({
});
it("dismisses one open question with a one-way note that starts no run, leaving the others open", async () => {
const f = await fixture2();
const id = await f.standing();
const id = await f.mainAgent();
const first = (await f.ask(id, { text: "Keep it running?", questions: [decisions[2]] })).message;
const second = (await f.ask(id, { text: "Which layout?" })).message;
const before = (await f.entries(id)).filter((entry2) => entry2.kind === "pi.assistant").length;
@@ -40954,7 +40954,7 @@ var init_goal_reminder_cases = __esm({
return () => timers.delete(timer);
} });
try {
const id = await f.standing();
const id = await f.mainAgent();
const { harness } = f.opened;
const goal = await import(
/* @vite-ignore */
+1 -1
View File
@@ -1,4 +1,4 @@
/** Minimal assertions for the imported Standing agent cases, without Node or Vitest. */
/** Minimal assertions for the imported Main agent cases, without Node or Vitest. */
export const cases: {name:string;run:()=>any}[]=[]
export const it=(name:string,run:()=>any)=>cases.push({name,run})
export const describe=(_name:string,run:()=>any)=>run()
+1 -1
View File
@@ -1,4 +1,4 @@
/** Standing agent goal and question-card cases execute in the world loader. */
/** Main agent goal and question-card cases execute in the world loader. */
import {fixture} from './goal-questions-fixture'
import {cases,cleanups} from './goal-questions-test-support'
const initial=await fixture()
+1 -1
View File
@@ -9,7 +9,7 @@ it('fires one goal reminder through automations',async()=>{
const timer={at:now+delay,callback};timers.add(timer);return()=>timers.delete(timer)
}})
try {
const id=await f.standing();const {harness}=f.opened
const id=await f.mainAgent();const {harness}=f.opened
const goal=await import(/* @vite-ignore */ `${f.base}/goal/server.ts`)
const {Automations}=await import(/* @vite-ignore */ `${f.base}/automations/server.ts`)
await goal.actions.start({text:'Ship the goal'},{host:f.host})
+12 -11
View File
@@ -38336,10 +38336,10 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), async standing() {
return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() {
if (!providers.has("models") || !providers.has("subagents")) return void 0;
const { standing: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").standing(name, context);
const { main_agent_name: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").mainAgent(name, context);
return { name, conversationId };
}, async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f2) => f2.extension.name === name);
@@ -38428,7 +38428,7 @@ async function fixture(clock = {}) {
/* @vite-ignore */
`${base}/messages/api.ts`
);
const { Standing } = await import(
const { MainAgent } = await import(
/* @vite-ignore */
`${base}/subagents/api.ts`
);
@@ -38453,11 +38453,11 @@ async function fixture(clock = {}) {
const c = await harness2.conversation(id2, BACKGROUND_CONTEXT);
await (await c.submit({ type: "input", content }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
};
const standing = async () => {
const mainAgent = async () => {
const c = await harness2.createConversation({ ownership: { kind: "ownerless" } }, BACKGROUND_CONTEXT);
await c.configure({ model: { provider: "faux", modelId: "faux-1" } }, BACKGROUND_CONTEXT);
await harness2.commit(async (tx) => {
(await tx.doc(Standing, root.id))["$standing"] = c.id;
(await tx.doc(MainAgent, root.id))["$main-agent"] = c.id;
}, BACKGROUND_CONTEXT);
return c.id;
};
@@ -38472,7 +38472,7 @@ async function fixture(clock = {}) {
await loader.close();
await harness2.close(BACKGROUND_CONTEXT);
await storage.close();
} }, standing, type, ask, answer: (args) => question2.actions.answer(args, action()), dismiss: (messageId) => question2.actions.dismiss({ messageId }, action()), log, entries: entries2, observed, settled, hear: (fn) => heard = fn };
} }, mainAgent, type, ask, answer: (args) => question2.actions.answer(args, action()), dismiss: (messageId) => question2.actions.dismiss({ messageId }, action()), log, entries: entries2, observed, settled, hear: (fn) => heard = fn };
}
// harness-api.ts
@@ -38542,7 +38542,7 @@ function harnessApi(harness2, models, extensions, context) {
};
try {
if (input.method === "GET" && path === "/conversations") {
const standing = await extensions.standing?.() ?? null;
const mainAgent = await extensions.mainAgent?.() ?? null;
const records = await harness2.commit(async (tx) => {
const all = [];
let cursor;
@@ -38561,7 +38561,7 @@ function harnessApi(harness2, models, extensions, context) {
const started = await harness2.snapshot(Started2, record2.id, context);
return { ...record2, title: metadata?.title ?? "", agent, starter: started?.by ?? null, concluded: concluded.has(record2.id), running: !!live?.run };
}));
reply(200, { conversations, standing });
reply(200, { conversations, main_agent: mainAgent });
return;
}
if (input.method === "GET" && path === "/events") {
@@ -38574,6 +38574,7 @@ function harnessApi(harness2, models, extensions, context) {
return;
}
if (input.method === "GET" && path === "/models") {
await models.refreshConfig?.();
reply(200, { models: models.getAllModels().map(({ id: id2, name, provider: provider2, contextWindow, maxTokens, reasoning, cost }) => ({ id: id2, name, provider: provider2, contextWindow, maxTokens, reasoning, cost })) });
return;
}
@@ -38677,12 +38678,12 @@ var curl = async (path, body) => {
assert(result.code === 0, `curl failed: ${result.stderr}`);
return JSON.parse(result.stdout);
};
var id = await f.standing();
var id = await f.mainAgent();
var question = await f.ask(id, { text: "Choose.", questions: [{ text: "Name", kind: "text", ref: "1" }] });
assert(question.message, "Question fixture");
var listed = await curl("/conversations");
assert(listed.conversations.some((c) => c.id === id && c.agent && !c.running), "Conversation list");
assert(listed.standing.name === "standing" && listed.standing.conversationId === id, "Conversation list identifies the configured standing conversation");
assert(listed.main_agent.name === "mainAgent" && listed.main_agent.conversationId === id, "Conversation list identifies the configured mainAgent conversation");
var initial = await curl(`/conversations/${id}/entries`);
var after = initial.entries.at(-1).id;
var answer2 = await curl("/extensions/questions/actions/answer", { args: { messageId: question.message.id, answers: [{ question: "Name", answer: "API choice" }] } });
+2 -2
View File
@@ -14,12 +14,12 @@ const curl=async(path:string,body?:any)=>{
assert(result.code===0,`curl failed: ${result.stderr}`);
return JSON.parse(result.stdout);
};
const id=await f.standing();
const id=await f.mainAgent();
const question=await f.ask(id,{text:'Choose.',questions:[{text:'Name',kind:'text',ref:'1'}]});
assert(question.message,'Question fixture');
const listed=await curl('/conversations');
assert(listed.conversations.some((c:any)=>c.id===id && c.agent && !c.running),'Conversation list');
assert(listed.standing.name==='standing'&&listed.standing.conversationId===id,'Conversation list identifies the configured standing conversation');
assert(listed.main_agent.name==='mainAgent'&&listed.main_agent.conversationId===id,'Conversation list identifies the configured mainAgent conversation');
const initial=await curl(`/conversations/${id}/entries`);
const after=initial.entries.at(-1).id;
const answer=await curl('/extensions/questions/actions/answer',{args:{messageId:question.message.id,answers:[{question:'Name',answer:'API choice'}]}});
+3 -3
View File
@@ -51,7 +51,7 @@ export function harnessApi(harness:any, models:any, extensions:any, context:any)
const reply=(status:number,body:any)=>{send(input.id,{status,body});send(input.id,null);};
try {
if(input.method==='GET' && path==='/conversations') {
const standing=await extensions.standing?.() ?? null;
const mainAgent=await extensions.mainAgent?.() ?? null;
const records=await harness.commit(async(tx:any)=>{
const all:any[]=[];let cursor:any;
do {const page=await tx.scanConversations({},256,cursor);all.push(...page.items);cursor=page.next;}while(cursor);
@@ -65,14 +65,14 @@ export function harnessApi(harness:any, models:any, extensions:any, context:any)
const started:any=await harness.snapshot(Started,record.id,context);
return {...record,title:metadata?.title??'',agent,starter:started?.by??null,concluded:concluded.has(record.id),running:!!live?.run};
}));
reply(200,{conversations,standing});return;
reply(200,{conversations,main_agent:mainAgent});return;
}
if(input.method==='GET' && path==='/events') {
const cursor=integer(url.searchParams.get('after'),0);
await follow(input,async(after)=>log.recent({since:after,order:'ascending',limit:256}),cursor);return;
}
if(input.method==='GET' && path==='/extensions') {reply(200,extensions.state());return;}
if(input.method==='GET' && path==='/models') {reply(200,{models:models.getAllModels().map(({id,name,provider,contextWindow,maxTokens,reasoning,cost}:any)=>({id,name,provider,contextWindow,maxTokens,reasoning,cost}))});return;}
if(input.method==='GET' && path==='/models') {await models.refreshConfig?.();reply(200,{models:models.getAllModels().map(({id,name,provider,contextWindow,maxTokens,reasoning,cost}:any)=>({id,name,provider,contextWindow,maxTokens,reasoning,cost}))});return;}
const action=/^\/extensions\/([^/]+)\/actions\/([^/]+)$/.exec(path);
if(input.method==='POST' && action) {
if(data.conversationId!==undefined && (!Number.isSafeInteger(data.conversationId)||data.conversationId<1)) fail(400,'Invalid conversation id');
+7 -8
View File
@@ -1,6 +1,6 @@
// The standing conversation serves the console and chat. SQLite stays outside the world.
// The main agent conversation serves the console and chat. SQLite stays outside the world.
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { proxyModels } from './proxy-models';
import { configuredModels, modelCatalog } from './models';
import { Harness, createRegistry } from '@earendil-works/pi-durable';
import { CodingTools } from './environment';
import { openStorage } from './sqlite';
@@ -9,8 +9,7 @@ import { CredentialRequests, startRequests } from './credentials';
import { serve } from './input';
import { harnessApi } from './harness-api';
import { loadExtensions } from './extensions';
const proxy = await Deno.core.ops.op_model_proxy();
const models = await proxyModels(proxy, () => Deno.core.ops.op_model_proxy());
const models = await configuredModels(() => Deno.core.ops.op_model_providers(), await modelCatalog());
const registry = createRegistry();
registry.install(CodingTools);
registry.install(CredentialRequests);
@@ -23,9 +22,9 @@ const chain = conversationChains(harness, context);
await harness.root(context, {agent:{cwd:'/src/sausage'}});
const stopRequests = await startRequests(harness, context);
const extensions = await loadExtensions(harness, registry, storage, models, context, [CodingTools, CredentialRequests]);
const standing = await extensions.standing();
if (!standing) throw new Error('The world needs the models and subagents extensions for its standing agent');
const conversation = (await harness.conversation(standing.conversationId, context))!;
const mainAgent = await extensions.mainAgent();
if (!mainAgent) throw new Error('The world needs the models and subagents extensions for its main agent');
const conversation = (await harness.conversation(mainAgent.conversationId, context))!;
const watch = await conversation.watch(context);
const emitted = new Map<number, string>();
let partial = '';
@@ -57,7 +56,7 @@ await serve(harness, context, globalThis.consoleEnabled ? async () => {
render(final.value);
await final.stop();
Deno.core.ops.op_output('\n');
} : undefined, api, async () => (await extensions.standing())!.conversationId);
} : undefined, api, async () => (await extensions.mainAgent())!.conversationId);
await api.close();
await extensions.close();
await stopRequests();
+4 -4
View File
@@ -37396,10 +37396,10 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), async standing() {
return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() {
if (!providers.has("models") || !providers.has("subagents")) return void 0;
const { standing: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").standing(name, context);
const { main_agent_name: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").mainAgent(name, context);
return { name, conversationId };
}, async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
@@ -38444,7 +38444,7 @@ var { SubmissionLanded } = await import(`${base}/lineage/refs.ts`);
var { Tally, abstract } = await import(`${base}/lineage/server.ts`);
var { normalizeMemoryOp, parseMemoryOps, applyMemoryOps, readMemory, memorizeTask } = await import(`${base}/lineage/memory.ts`);
var { Seen, mainOf } = await import(`${base}/lineage/catchup.ts`);
var primaryId = await globalThis.subagentService.standing("standing", BACKGROUND_CONTEXT);
var primaryId = await globalThis.subagentService.mainAgent("mainAgent", BACKGROUND_CONTEXT);
var primary = await harness.conversation(primaryId, BACKGROUND_CONTEXT);
var entries2 = async (conversation) => (await conversation.context(BACKGROUND_CONTEXT)).entries;
var replies = async () => (await entries2(primary)).filter((e) => e.kind === "board.reply");
+1 -1
View File
@@ -59,7 +59,7 @@ const {SubmissionLanded} = await import(`${base}/lineage/refs.ts`);
const {Tally, abstract} = await import(`${base}/lineage/server.ts`);
const {normalizeMemoryOp, parseMemoryOps, applyMemoryOps, readMemory, memorizeTask} = await import(`${base}/lineage/memory.ts`);
const {Seen, mainOf} = await import(`${base}/lineage/catchup.ts`);
const primaryId = await globalThis.subagentService.standing('standing', context);
const primaryId = await globalThis.subagentService.mainAgent('mainAgent', context);
const primary = (await harness.conversation(primaryId, context))!;
const entries = async (conversation: any) => (await conversation.context(context)).entries;
const replies = async () => (await entries(primary)).filter((e: any) => e.kind === 'board.reply');
File diff suppressed because one or more lines are too long
+58
View File
@@ -0,0 +1,58 @@
/** Configured providers with models.dev metadata and pi-ai API adapters. */
import {createModels, createProvider} from '@earendil-works/pi-ai/models';
import {anthropicMessagesApi} from '@earendil-works/pi-ai/api/anthropic-messages.lazy';
import {openAIResponsesApi} from '@earendil-works/pi-ai/api/openai-responses.lazy';
import {openAICompletionsApi} from '@earendil-works/pi-ai/api/openai-completions.lazy';
export const CONFIG = '/etc/sausage/config.yaml';
const adapters = {'anthropic-messages': anthropicMessagesApi, 'openai-responses': openAIResponsesApi,
'openai-chat-completions': openAICompletionsApi};
export function resolveModel(provider: string, settings: any, entry: any, catalog: any) {
const override = typeof entry === 'string' ? {id:entry} : entry;
const id = override.id;
const known = catalog[settings.catalog ?? provider]?.models?.[id];
const metadata = {context_window:known?.limit?.context, max_tokens:known?.limit?.output,
reasoning:known?.reasoning, cost:known?.cost, ...override};
const cost = {...known?.cost, ...override.cost};
const missing = ['context_window','max_tokens','reasoning'].filter(key=>metadata[key] === undefined);
for (const key of ['input','output','cache_read','cache_write']) if (cost[key] === undefined) missing.push(`cost.${key}`);
if (missing.length) throw new Error(`${CONFIG}: ${provider}/${id} lacks ${missing.join(', ')}`);
const extra = Object.keys(override).filter(key=>!['id','context_window','max_tokens','reasoning','cost'].includes(key));
const extraCost = Object.keys(override.cost ?? {}).filter(key=>!['input','output','cache_read','cache_write'].includes(key));
if (extra.length || extraCost.length) throw new Error(`${CONFIG}: invalid overrides for ${provider}/${id}: ${[...extra,...extraCost].join(', ')}`);
return {id, name:known?.name ?? id, provider, api:settings.type === 'openai-chat-completions' ? 'openai-completions' : settings.type,
baseUrl:settings.base_url, contextWindow:metadata.context_window, maxTokens:metadata.max_tokens,
reasoning:metadata.reasoning, input:known?.modalities?.input?.filter((type:string)=>['text','image'].includes(type)) ?? ['text'],
cost:{input:cost.input, output:cost.output, cacheRead:cost.cache_read, cacheWrite:cost.cache_write}};
}
export async function modelCatalog() {
let updated;
try {
const response = await fetch('https://models.dev/api.json',{signal:AbortSignal.timeout(5000)});
if (response.ok) updated = await response.json();
} catch { /* Cached metadata and the packaged snapshot also work offline. */ }
return Deno.core.ops.op_model_catalog(updated ?? null);
}
export async function configuredModels(readProviders:()=>Promise<any>, catalog:any) {
const models:any = createModels();
let installed = new Set<string>();
models.refreshConfig = async () => {
const settings = await readProviders();
const next = new Set<string>(Object.keys(settings));
for (const name of installed) if (!next.has(name)) models.deleteProvider(name);
for (const [name, value] of Object.entries(settings) as [string,any][]) {
const adapter = adapters[value.type];
if (!adapter) throw new Error(`${CONFIG}: unknown API type for provider ${name}: ${value.type}`);
const entries = (value.models ?? []).map((entry:any)=>resolveModel(name,value,entry,catalog));
models.setProvider(createProvider({id:name,name,baseUrl:value.base_url,models:entries,api:adapter(),
auth:{apiKey:{name:`${name} API key`,resolve:async()=>{
const current = (await readProviders())[name];
if (!current?.api_key || !current.base_url) throw new Error(`${CONFIG}: set providers.${name}.api_key and base_url before a model request`);
return {auth:{apiKey:current.api_key,baseUrl:current.base_url},source:CONFIG};
}}}}));
}
installed = next;
};
await models.refreshConfig();
return models;
}
+15 -15
View File
@@ -37396,10 +37396,10 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), async standing() {
return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() {
if (!providers.has("models") || !providers.has("subagents")) return void 0;
const { standing: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").standing(name, context);
const { main_agent_name: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").mainAgent(name, context);
return { name, conversationId };
}, async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
@@ -38360,7 +38360,7 @@ async function openStorage(path) {
var textOf = (content) => typeof content === "string" ? content : (content ?? []).filter((part) => part.type === "text").map((part) => part.text).join("\n");
function answerFor(rules, transcript) {
const system = JSON.stringify(transcript.messages.filter((message) => message.role === "system"));
const role = /P3 role:([a-z]+)/.exec(system)?.[1];
const role = /P3 role:([a-zA-Z0-9_-]+)/.exec(system)?.[1];
const last = transcript.messages.findLast((message) => message.role !== "system");
const rule = rules.find((rule2) => rule2.role === role && (!rule2.match.kind || rule2.match.kind === last?.role) && textOf(last?.content).includes(rule2.match.text));
if (!rule) throw new Error(`No scripted rule for ${role}: ${JSON.stringify(last)}`);
@@ -38404,10 +38404,10 @@ testReplay();
if (globalThis.p3Mode !== "unit") {
const workerId = (transcript) => Number(/Started subagent (\d+)/.exec(JSON.stringify(transcript.messages))?.[1]);
const rules = [
{ id: "standing-branch", role: "standing", match: { kind: "user", text: "launch scenario" }, answer: { tools: [{ name: "branch", args: { repo: "/src/demo", isolated: true, prompt: "begin" } }] } },
{ id: "standing-probe", role: "standing", match: { kind: "user", text: "launch branch" }, answer: { tools: [{ name: "branch", args: { repo: "/src/demo", prompt: "branch probe" } }] } },
{ id: "mainAgent-branch", role: "mainAgent", match: { kind: "user", text: "launch scenario" }, answer: { tools: [{ name: "branch", args: { repo: "/src/demo", isolated: true, prompt: "begin" } }] } },
{ id: "mainAgent-probe", role: "mainAgent", match: { kind: "user", text: "launch branch" }, answer: { tools: [{ name: "branch", args: { repo: "/src/demo", prompt: "branch probe" } }] } },
{ id: "coordinator-probe", role: "coordinator", match: { kind: "user", text: "branch probe" }, answer: { text: "branch complete" } },
{ id: "standing-receipt", role: "standing", match: { text: "" }, answer: { text: "Receipt noted." } },
{ id: "mainAgent-receipt", role: "mainAgent", match: { text: "" }, answer: { text: "Receipt noted." } },
{ id: "coordinator-echo", role: "coordinator", match: { kind: "user", text: "Receipt noted." }, answer: { text: "Acknowledged." } },
{ id: "start-worker", role: "coordinator", match: { kind: "user", text: "begin" }, answer: { tools: [{ name: "subagent", args: { action: "start", agent: "worker", isolate: "worktree", task: "build feature", background: true } }] } },
{ id: "start-reviewer", role: "coordinator", match: { kind: "user", text: "worker complete" }, answer: { tools: [{ name: "subagent", args: { action: "start", agent: "reviewer", isolate: "none", task: "review feature", background: true } }] } },
@@ -38427,7 +38427,7 @@ if (globalThis.p3Mode !== "unit") {
const harness = await Harness.open(storage, { models, registry, settings: { retry: { enabled: false }, get extensions() {
return registry.snapshot().installed();
} } }, BACKGROUND_CONTEXT);
const root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "faux", modelId: "faux-1" }, instructions: "P3 role:standing", cwd: "/src/demo" } });
const root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "faux", modelId: "faux-1" }, instructions: "P3 role:mainAgent", cwd: "/src/demo" } });
if (globalThis.p3Mode === "kill-forward") {
const install = registry.install.bind(registry);
registry.install = (extension) => install(extension.name !== "workspaces" ? extension : { ...extension, tasks: extension.tasks.map((task) => task.definition.name !== "workspaces.lander" ? task : { definition: { ...task.definition, phases: { ...task.definition.phases, land: async (record2, runtime, ctx) => {
@@ -38481,12 +38481,12 @@ if (globalThis.p3Mode !== "unit") {
if (globalThis.p3Mode === "load-all") {
assert((await root.agent(BACKGROUND_CONTEXT)).tools.some((tool) => tool.name === "branch"), "Every extension must load");
} else {
const standing = await harness.conversation(await globalThis.p3Subagents.standing("standing", BACKGROUND_CONTEXT), BACKGROUND_CONTEXT);
const mainAgent = await harness.conversation(await globalThis.p3Subagents.mainAgent("mainAgent", BACKGROUND_CONTEXT), BACKGROUND_CONTEXT);
harness.resume();
const probe = globalThis.p3Mode.startsWith("branch");
if (globalThis.p3Mode === "branch" || globalThis.p3Mode.startsWith("kill-")) {
await (await standing.submit({ type: "input", content: probe ? "launch branch" : "launch scenario", requestId: "p3-launch" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
const entries3 = (await standing.context(BACKGROUND_CONTEXT)).entries;
await (await mainAgent.submit({ type: "input", content: probe ? "launch branch" : "launch scenario", requestId: "p3-launch" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
const entries3 = (await mainAgent.context(BACKGROUND_CONTEXT)).entries;
assert(!JSON.stringify(entries3).includes('"isError":true'), `Branch tool failed: ${JSON.stringify(entries3)}`);
}
if (globalThis.p3Mode.startsWith("kill-")) await new Promise(() => {
@@ -38498,12 +38498,12 @@ if (globalThis.p3Mode !== "unit") {
await new Promise((resolve2) => setTimeout(resolve2, 0));
}
};
const replies = async () => (await standing.context(BACKGROUND_CONTEXT)).entries.filter((entry2) => entry2.kind === "board.reply");
const replies = async () => (await mainAgent.context(BACKGROUND_CONTEXT)).entries.filter((entry2) => entry2.kind === "board.reply");
await wait(async () => (await replies()).length > 0, "No branch reply");
const received = await replies();
const coordinator = await harness.conversation(received[0].data.from, BACKGROUND_CONTEXT);
assert((await coordinator.agent(BACKGROUND_CONTEXT)).instructions.includes("P3 role:coordinator"), "Branch must use coordinator definition");
assert((await conversationChains(harness, BACKGROUND_CONTEXT)(coordinator.id)).includes(String(standing.id)), "D63 must include standing");
assert((await conversationChains(harness, BACKGROUND_CONTEXT)(coordinator.id)).includes(String(mainAgent.id)), "D63 must include mainAgent");
if (!probe) {
await wait(async () => Object.keys((await harness.snapshot(globalThis.p3Workspaces.doc, root.id, BACKGROUND_CONTEXT)).results).length === 1, "No landing");
await wait(async () => (await harness.snapshot(globalThis.p3Workspaces.doc, root.id, BACKGROUND_CONTEXT)).queue.length === 0, "Lander did not report");
@@ -38522,9 +38522,9 @@ if (globalThis.p3Mode !== "unit") {
assert(!await fs.stat(worker.path).then(() => true, () => false), "Released checkout must not exist");
assert(Object.values(index.results).every((result) => result.outcome === "landed"), `Landing failed: ${JSON.stringify(index)}`);
assert((await globalThis.p3Host.eventLog.recent({ events: [{ kind: "event.workspaces.landed" }] })).length === 1, "Expected one landed event");
const standingEntries = (await standing.context(BACKGROUND_CONTEXT)).entries;
const mainAgentEntries = (await mainAgent.context(BACKGROUND_CONTEXT)).entries;
const landed = Object.values(index.results)[0];
const cards = standingEntries.filter((entry2) => entry2.kind === "board.message" && entry2.data.text.startsWith("[lander] Submission") && entry2.data.text.includes(" landed as "));
const cards = mainAgentEntries.filter((entry2) => entry2.kind === "board.message" && entry2.data.text.startsWith("[lander] Submission") && entry2.data.text.includes(" landed as "));
assert(cards.length === 1 && cards[0].data.from === coordinator.id && cards[0].data.text.includes(landed.sha), `Expected one landing card through messages: ${JSON.stringify(cards)}`);
const repliesNow = await replies();
assert(repliesNow.length === 1 && !repliesNow[0].data.failed && new Set(repliesNow.map((entry2) => entry2.data.id)).size === repliesNow.length, `Duplicate board.reply: ${JSON.stringify(repliesNow)}`);
+11 -11
View File
@@ -12,10 +12,10 @@ testReplay();
if (globalThis.p3Mode !== 'unit') {
const workerId = (transcript: any) => Number(/Started subagent (\d+)/.exec(JSON.stringify(transcript.messages))?.[1]);
const rules: Rule[] = [
{ id: 'standing-branch', role: 'standing', match: { kind: 'user', text: 'launch scenario' }, answer: { tools: [{ name: 'branch', args: { repo: '/src/demo', isolated: true, prompt: 'begin' } }] } },
{ id: 'standing-probe', role: 'standing', match: { kind: 'user', text: 'launch branch' }, answer: { tools: [{ name: 'branch', args: { repo: '/src/demo', prompt: 'branch probe' } }] } },
{ id: 'mainAgent-branch', role: 'mainAgent', match: { kind: 'user', text: 'launch scenario' }, answer: { tools: [{ name: 'branch', args: { repo: '/src/demo', isolated: true, prompt: 'begin' } }] } },
{ id: 'mainAgent-probe', role: 'mainAgent', match: { kind: 'user', text: 'launch branch' }, answer: { tools: [{ name: 'branch', args: { repo: '/src/demo', prompt: 'branch probe' } }] } },
{ id: 'coordinator-probe', role: 'coordinator', match: { kind: 'user', text: 'branch probe' }, answer: { text: 'branch complete' } },
{ id: 'standing-receipt', role: 'standing', match: { text: '' }, answer: { text: 'Receipt noted.' } },
{ id: 'mainAgent-receipt', role: 'mainAgent', match: { text: '' }, answer: { text: 'Receipt noted.' } },
{ id: 'coordinator-echo', role: 'coordinator', match: { kind: 'user', text: 'Receipt noted.' }, answer: { text: 'Acknowledged.' } },
{ id: 'start-worker', role: 'coordinator', match: { kind: 'user', text: 'begin' }, answer: { tools: [{ name: 'subagent', args: { action: 'start', agent: 'worker', isolate: 'worktree', task: 'build feature', background: true } }] } },
{ id: 'start-reviewer', role: 'coordinator', match: { kind: 'user', text: 'worker complete' }, answer: { tools: [{ name: 'subagent', args: { action: 'start', agent: 'reviewer', isolate: 'none', task: 'review feature', background: true } }] } },
@@ -32,7 +32,7 @@ if (globalThis.p3Mode !== 'unit') {
models.setProvider(scriptedModel(rules, rule => Deno.core.ops.op_output(`scripted rule: ${rule}\n`)));
const storage = await openStorage(globalThis.storagePath), registry = createRegistry();
const harness = await Harness.open(storage, { models, registry, settings: { retry: { enabled: false }, get extensions() { return registry.snapshot().installed(); } } }, context);
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, instructions: 'P3 role:standing', cwd: '/src/demo' } });
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, instructions: 'P3 role:mainAgent', cwd: '/src/demo' } });
// Stop after the durable lander commit records landed.sha, before its event/report commit.
if (globalThis.p3Mode === 'kill-forward') {
const install = registry.install.bind(registry);
@@ -85,12 +85,12 @@ if (globalThis.p3Mode !== 'unit') {
if (globalThis.p3Mode === 'load-all') {
assert((await root.agent(context)).tools.some((tool: any) => tool.name === 'branch'), 'Every extension must load');
} else {
const standing = (await harness.conversation(await globalThis.p3Subagents.standing('standing', context), context))!;
const mainAgent = (await harness.conversation(await globalThis.p3Subagents.mainAgent('mainAgent', context), context))!;
harness.resume();
const probe = globalThis.p3Mode.startsWith('branch');
if (globalThis.p3Mode === 'branch' || globalThis.p3Mode.startsWith('kill-')) {
await (await standing.submit({ type: 'input', content: probe ? 'launch branch' : 'launch scenario', requestId: 'p3-launch' }, context)).wait(context);
const entries = (await standing.context(context)).entries;
await (await mainAgent.submit({ type: 'input', content: probe ? 'launch branch' : 'launch scenario', requestId: 'p3-launch' }, context)).wait(context);
const entries = (await mainAgent.context(context)).entries;
assert(!JSON.stringify(entries).includes('"isError":true'), `Branch tool failed: ${JSON.stringify(entries)}`);
}
if (globalThis.p3Mode.startsWith('kill-')) await new Promise(() => {});
@@ -101,12 +101,12 @@ if (globalThis.p3Mode !== 'unit') {
await new Promise(resolve => setTimeout(resolve, 0));
}
};
const replies = async () => (await standing.context(context)).entries.filter((entry: any) => entry.kind === 'board.reply');
const replies = async () => (await mainAgent.context(context)).entries.filter((entry: any) => entry.kind === 'board.reply');
await wait(async () => (await replies()).length > 0, 'No branch reply');
const received = await replies();
const coordinator = (await harness.conversation(received[0].data.from, context))!;
assert((await coordinator.agent(context)).instructions.includes('P3 role:coordinator'), 'Branch must use coordinator definition');
assert((await conversationChains(harness, context)(coordinator.id)).includes(String(standing.id)), 'D63 must include standing');
assert((await conversationChains(harness, context)(coordinator.id)).includes(String(mainAgent.id)), 'D63 must include mainAgent');
if (!probe) {
await wait(async () => Object.keys((await harness.snapshot(globalThis.p3Workspaces.doc, root.id, context)).results).length === 1, 'No landing');
await wait(async () => (await harness.snapshot(globalThis.p3Workspaces.doc, root.id, context)).queue.length === 0, 'Lander did not report');
@@ -125,9 +125,9 @@ if (globalThis.p3Mode !== 'unit') {
assert(!await fs.stat(worker.path).then(() => true, () => false), 'Released checkout must not exist');
assert(Object.values(index.results).every((result: any) => result.outcome === 'landed'), `Landing failed: ${JSON.stringify(index)}`);
assert((await globalThis.p3Host.eventLog.recent({ events: [{ kind: 'event.workspaces.landed' }] })).length === 1, 'Expected one landed event');
const standingEntries = (await standing.context(context)).entries;
const mainAgentEntries = (await mainAgent.context(context)).entries;
const landed = Object.values(index.results)[0] as any;
const cards = standingEntries.filter((entry: any) => entry.kind === 'board.message' && entry.data.text.startsWith('[lander] Submission') && entry.data.text.includes(' landed as '));
const cards = mainAgentEntries.filter((entry: any) => entry.kind === 'board.message' && entry.data.text.startsWith('[lander] Submission') && entry.data.text.includes(' landed as '));
assert(cards.length === 1 && cards[0].data.from === coordinator.id && cards[0].data.text.includes(landed.sha), `Expected one landing card through messages: ${JSON.stringify(cards)}`);
const repliesNow = await replies();
assert(repliesNow.length === 1 && !repliesNow[0].data.failed && new Set(repliesNow.map((entry: any) => entry.data.id)).size === repliesNow.length, `Duplicate board.reply: ${JSON.stringify(repliesNow)}`);
+1 -1
View File
@@ -2,7 +2,7 @@
"private": true,
"type": "module",
"scripts": {
"bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js && esbuild p4-test.ts --bundle --format=esm --platform=browser --outfile=p4-test-bundle.js && esbuild harness-api-test.ts --bundle --format=esm --platform=browser --outfile=harness-api-test-bundle.js && esbuild proxy-models-test.ts --bundle --format=esm --platform=browser --outfile=proxy-models-test-bundle.js"
"bundle": "export NODE_PATH=\"$PWD/node_modules\"; nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js && esbuild p4-test.ts --bundle --format=esm --platform=browser --outfile=p4-test-bundle.js && esbuild harness-api-test.ts --bundle --format=esm --platform=browser --outfile=harness-api-test-bundle.js && esbuild provider-models-test.ts --bundle --format=esm --platform=browser --outfile=provider-models-test-bundle.js"
},
"dependencies": {
"@earendil-works/chord": "1.5.0",
+17 -17
View File
@@ -38336,10 +38336,10 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), async standing() {
return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() {
if (!providers.has("models") || !providers.has("subagents")) return void 0;
const { standing: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").standing(name, context);
const { main_agent_name: name } = await providers.get("models").discover(repo);
const conversationId = await providers.get("subagents").mainAgent(name, context);
return { name, conversationId };
}, async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
@@ -38391,7 +38391,7 @@ var todosModule = await import(
`${base}/todos/server.ts`
);
var { Plan, TaskIndex, Branch, Started: Started2 } = planModule;
var { Todos, Standing, add, mark, noteFor, DONE_LIMIT } = todosModule;
var { Todos, MainAgent, add, mark, noteFor, DONE_LIMIT } = todosModule;
var call = async (args, id = root.id, name = "plan") => {
const conversation = await harness.conversation(id, BACKGROUND_CONTEXT);
await conversation.configure({ model: { provider: "faux", modelId: "faux-1" }, modelTools: { add: [todosModule.default().tools[0]] } }, BACKGROUND_CONTEXT);
@@ -38496,19 +38496,19 @@ try {
assert(Object.keys((await harness.snapshot(Plan, branch.id, BACKGROUND_CONTEXT)).epics).length === 1, "Fork inherited plan");
equal(JSON.parse(text(await call({ action: "show" }, branch.id))), Plan.definition.initial());
assert((await harness.snapshot(Started2, branch.id, BACKGROUND_CONTEXT)).started, "Branch started");
const STANDING = 5;
const MAIN_AGENT = 5;
const fakeTx = () => {
const docs = /* @__PURE__ */ new Map();
return { doc: async ({ definition: def }, id) => {
const key = `${def.kind}:${id}`;
if (!docs.has(key)) docs.set(key, def.kind === "standing.standing-agents" ? { "$standing": STANDING } : def.initial());
if (!docs.has(key)) docs.set(key, def.kind === "main-agent.index" ? { "$main-agent": MAIN_AGENT } : def.initial());
return docs.get(key);
} };
};
const list = async (tx) => (await tx.doc(Todos, 1)).items;
{
const tx = fakeTx();
equal((await add(tx, " Deploy fleet ", "standing")).text, "Deploy fleet");
equal((await add(tx, " Deploy fleet ", "mainAgent")).text, "Deploy fleet");
assert((await add(tx, "Rotate the key", "user")).id === 2, "Sequential todo ids");
await rejects(() => add(tx, " ", "user"), "nonempty");
await rejects(() => add(tx, "x", "lander"), "owner");
@@ -38516,7 +38516,7 @@ try {
}
{
const tx = fakeTx();
for (let i = 0; i < DONE_LIMIT + 2; i++) await add(tx, `t${i}`, "standing");
for (let i = 0; i < DONE_LIMIT + 2; i++) await add(tx, `t${i}`, "mainAgent");
assert(typeof (await mark(tx, 1, true)).done === "number", "Tick");
assert((await mark(tx, 1, false)).done === void 0, "Reopen");
for (const todo of (await list(tx)).slice(0, -1)) todo.done = todo.id;
@@ -38525,9 +38525,9 @@ try {
await rejects(() => mark(tx, 999, true), "No to-do 999");
}
{
const tx = fakeTx(), content = async () => (await noteFor(tx, STANDING, 0))?.entries[0].model[0].content;
const tx = fakeTx(), content = async () => (await noteFor(tx, MAIN_AGENT, 0))?.entries[0].model[0].content;
assert(await content() === void 0, "Empty note");
await add(tx, "Mine", "standing");
await add(tx, "Mine", "mainAgent");
await add(tx, "Theirs", "user");
equal(await content(), "Open to-dos (todo tool: done when finished):\nYours:\n- 1: Mine\nThe user's:\n- 2: Theirs");
assert(await content() === void 0, "Unchanged note");
@@ -38539,17 +38539,17 @@ try {
{
const tx = fakeTx(), extension2 = todosModule.default(), tool2 = extension2.tools[0];
const run2 = (conversationId, args) => tool2.execute(args, { conversationId, commit: (fn) => fn(tx) }, BACKGROUND_CONTEXT);
await rejects(() => run2(9, { action: "list" }), "Only the standing agent");
equal(await run2(STANDING, { action: "add", text: "Ship it", owner: "user" }), { output: [{ type: "text", text: "Added 1 for user: Ship it" }] });
equal(await run2(STANDING, { action: "list" }), { output: [{ type: "text", text: "The user's:\n- 1: Ship it" }] });
await rejects(() => run2(9, { action: "list" }), "Only the main agent");
equal(await run2(MAIN_AGENT, { action: "add", text: "Ship it", owner: "user" }), { output: [{ type: "text", text: "Added 1 for user: Ship it" }] });
equal(await run2(MAIN_AGENT, { action: "list" }), { output: [{ type: "text", text: "The user's:\n- 1: Ship it" }] });
const before = (conversationId) => extension2.hooks[0].handlers.beforeInput({}, { tx, conversationId }, BACKGROUND_CONTEXT);
assert(await before(9) === void 0, "Nonstanding note");
assert(await before(STANDING) !== void 0, "Standing note");
assert(await before(9) === void 0, "NonmainAgent note");
assert(await before(MAIN_AGENT) !== void 0, "MainAgent note");
}
await harness.commit(async (tx) => {
(await tx.doc(Standing, root.id))["$standing"] = root.id;
(await tx.doc(MainAgent, root.id))["$main-agent"] = root.id;
}, BACKGROUND_CONTEXT);
assert(text(await call({ action: "add", text: "Real world", owner: "standing" }, root.id, "todo")).includes("Added 1"), "Loaded todo tool");
assert(text(await call({ action: "add", text: "Real world", owner: "mainAgent" }, root.id, "todo")).includes("Added 1"), "Loaded todo tool");
assert(text(await call({ action: "list" }, root.id, "todo")).includes("Real world"), "Loaded todo list");
assert((await root.context(BACKGROUND_CONTEXT)).entries.some((entry2) => entry2.kind === "todos.note"), "Real beforeInput note");
await fs.write("/saved", JSON.stringify({ plan: await harness.snapshot(Plan, root.id, BACKGROUND_CONTEXT), index: await harness.snapshot(TaskIndex, root.id, BACKGROUND_CONTEXT), todos: await harness.snapshot(Todos, root.id, BACKGROUND_CONTEXT) }));
+13 -13
View File
@@ -1,4 +1,4 @@
/** The six Standing agent plan/todos cases, plus real-loader turns and branch reset. */
/** The six Main agent plan/todos cases, plus real-loader turns and branch reset. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { createModels } from '@earendil-works/pi-ai/models';
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
@@ -18,7 +18,7 @@ const base = `file:///run/sausage/extensions/${loader.state().good}/extensions`;
const planModule = await import(/* @vite-ignore */ `${base}/plan/server.ts`);
const todosModule = await import(/* @vite-ignore */ `${base}/todos/server.ts`);
const {Plan,TaskIndex,Branch,Started} = planModule;
const {Todos,Standing,add,mark,noteFor,DONE_LIMIT} = todosModule;
const {Todos,MainAgent,add,mark,noteFor,DONE_LIMIT} = todosModule;
const call = async (args:any, id=root.id, name='plan') => {
const conversation = await harness.conversation(id,context);
await conversation.configure({model:{provider:'faux',modelId:'faux-1'},modelTools:{add:[todosModule.default().tools[0]]}},context);
@@ -113,37 +113,37 @@ try {
assert((await harness.snapshot(Started,branch.id,context)).started,'Branch started');
// Todos cases 1-4 retain the original small transaction fixture.
const STANDING=5;
const fakeTx=()=>{const docs=new Map();return {doc:async({definition:def}:any,id:number)=>{const key=`${def.kind}:${id}`;if(!docs.has(key))docs.set(key,def.kind==='standing.standing-agents'?{'$standing':STANDING}:def.initial());return docs.get(key);}};};
const MAIN_AGENT=5;
const fakeTx=()=>{const docs=new Map();return {doc:async({definition:def}:any,id:number)=>{const key=`${def.kind}:${id}`;if(!docs.has(key))docs.set(key,def.kind==='main-agent.index'?{'$main-agent':MAIN_AGENT}:def.initial());return docs.get(key);}};};
const list=async(tx:any)=>(await tx.doc(Todos,1)).items;
{
const tx=fakeTx();equal((await add(tx,' Deploy fleet ','standing')).text,'Deploy fleet');
const tx=fakeTx();equal((await add(tx,' Deploy fleet ','mainAgent')).text,'Deploy fleet');
assert((await add(tx,'Rotate the key','user')).id===2,'Sequential todo ids');
await rejects(()=>add(tx,' ','user'),'nonempty');await rejects(()=>add(tx,'x','lander'),'owner');equal((await list(tx)).map((todo:any)=>todo.id),[1,2]);
}
{
const tx=fakeTx();for(let i=0;i<DONE_LIMIT+2;i++)await add(tx,`t${i}`,'standing');
const tx=fakeTx();for(let i=0;i<DONE_LIMIT+2;i++)await add(tx,`t${i}`,'mainAgent');
assert(typeof(await mark(tx,1,true)).done==='number','Tick');assert((await mark(tx,1,false)).done===undefined,'Reopen');
for(const todo of (await list(tx)).slice(0,-1))todo.done=todo.id;
await mark(tx,DONE_LIMIT+2,true);equal((await list(tx)).map((todo:any)=>todo.id),Array.from({length:DONE_LIMIT},(_,i)=>i+3));await rejects(()=>mark(tx,999,true),'No to-do 999');
}
{
const tx=fakeTx(), content=async()=>(await noteFor(tx,STANDING,0))?.entries[0].model[0].content;
assert(await content()===undefined,'Empty note');await add(tx,'Mine','standing');await add(tx,'Theirs','user');
const tx=fakeTx(), content=async()=>(await noteFor(tx,MAIN_AGENT,0))?.entries[0].model[0].content;
assert(await content()===undefined,'Empty note');await add(tx,'Mine','mainAgent');await add(tx,'Theirs','user');
equal(await content(),"Open to-dos (todo tool: done when finished):\nYours:\n- 1: Mine\nThe user's:\n- 2: Theirs");assert(await content()===undefined,'Unchanged note');
await mark(tx,1,true);await mark(tx,2,true);equal(await content(),'No to-dos are open.');assert(await content()===undefined,'Unchanged empty note');
}
{
const tx=fakeTx(), extension=todosModule.default(), tool=extension.tools[0];
const run=(conversationId:number,args:any)=>tool.execute(args,{conversationId,commit:(fn:any)=>fn(tx)},context);
await rejects(()=>run(9,{action:'list'}),'Only the standing agent');equal(await run(STANDING,{action:'add',text:'Ship it',owner:'user'}),{output:[{type:'text',text:'Added 1 for user: Ship it'}]});
equal(await run(STANDING,{action:'list'}),{output:[{type:'text',text:"The user's:\n- 1: Ship it"}]});
await rejects(()=>run(9,{action:'list'}),'Only the main agent');equal(await run(MAIN_AGENT,{action:'add',text:'Ship it',owner:'user'}),{output:[{type:'text',text:'Added 1 for user: Ship it'}]});
equal(await run(MAIN_AGENT,{action:'list'}),{output:[{type:'text',text:"The user's:\n- 1: Ship it"}]});
const before=(conversationId:number)=>extension.hooks[0].handlers.beforeInput({}, {tx,conversationId},context);
assert(await before(9)===undefined,'Nonstanding note');assert(await before(STANDING)!==undefined,'Standing note');
assert(await before(9)===undefined,'NonmainAgent note');assert(await before(MAIN_AGENT)!==undefined,'MainAgent note');
}
// The real world also calls todo through the loaded factory and places its note.
await harness.commit(async(tx:any)=>{(await tx.doc(Standing,root.id))['$standing']=root.id;},context);
assert(text(await call({action:'add',text:'Real world',owner:'standing'},root.id,'todo')).includes('Added 1'),'Loaded todo tool');
await harness.commit(async(tx:any)=>{(await tx.doc(MainAgent,root.id))['$main-agent']=root.id;},context);
assert(text(await call({action:'add',text:'Real world',owner:'mainAgent'},root.id,'todo')).includes('Added 1'),'Loaded todo tool');
assert(text(await call({action:'list'},root.id,'todo')).includes('Real world'),'Loaded todo list');
assert((await root.context(context)).entries.some((entry:any)=>entry.kind==='todos.note'),'Real beforeInput note');
await fs.write('/saved',JSON.stringify({plan:await harness.snapshot(Plan,root.id,context),index:await harness.snapshot(TaskIndex,root.id,context),todos:await harness.snapshot(Todos,root.id,context)}));
File diff suppressed because one or more lines are too long
+58
View File
@@ -0,0 +1,58 @@
/** Real pi-ai API adapters, catalog metadata, live provider edits, and durable usage. */
import {configuredModels,resolveModel,modelCatalog,CONFIG} from './models';
import {fs} from './extensions';
import {environment} from './environment';
import {BACKGROUND_CONTEXT as context} from '@earendil-works/chord/context';
import {Harness,createRegistry} from '@earendil-works/pi-durable';
import {openStorage} from './sqlite';
import modelExtension from '../../extensions/models/server';
const assert=(ok:any,message:string)=>{if(!ok)throw new Error(message)};
const fetch=globalThis.fetch;
globalThis.fetch=async()=>{throw new Error('offline')};
const catalog=await modelCatalog();
assert(catalog.openai.models['gpt-6.1-sol'],'packaged snapshot works offline');
await Deno.core.ops.op_model_catalog({...catalog,cache_fixture:{id:'cache_fixture',models:{}}});
assert((await modelCatalog()).cache_fixture.id==='cache_fixture','persisted metadata cache works offline');
await Deno.core.ops.op_model_catalog(catalog);
globalThis.fetch=fetch;
let config=globalThis.providerSettings;
const readProviders=()=>Deno.core.ops.op_model_providers();
const models=await configuredModels(readProviders,catalog);
const defaults=models.getModel('responses','gpt-6.1-sol');
assert(defaults.contextWindow===catalog.openai.models['gpt-6.1-sol'].limit.context,'catalog context default');
const override=resolveModel('custom',{...config.providers.responses,catalog:'openai'},{id:'gpt-6.1-sol',context_window:272000},catalog);
assert(override.contextWindow===272000&&override.maxTokens===defaults.maxTokens&&override.cost.input===defaults.cost.input,'context-only override preserves catalog values');
let failure='';
try{resolveModel('custom',config.providers.responses,'unknown-model',catalog)}catch(error){failure=String(error)}
for(const text of [CONFIG,'custom/unknown-model','context_window','max_tokens','reasoning','cost.input'])assert(failure.includes(text),`unknown model names missing ${text}`);
const supplied={id:'unknown-model',context_window:32000,max_tokens:4000,reasoning:false,cost:{input:1,output:2,cache_read:0,cache_write:0}};
assert(resolveModel('custom',config.providers.responses,supplied,catalog).cost.output===2,'unknown model accepts complete overrides');
for(const [provider,id] of [['messages','claude-opus-5-5'],['responses','gpt-6.1-sol'],['chat','gpt-6.1-sol']]){
const model=models.getModel(provider,id);
const result=await models.completeSimple(model,{messages:[{role:'user',content:'Say hello.',timestamp:Date.now()}]},{maxTokens:128});
assert(result.stopReason!=='error',`${provider}: ${result.errorMessage}`);
assert(result.content.some(block=>block.type==='text'&&block.text==='hello'),`${provider}: streamed text`);
assert(result.usage.cost.total>0,`${provider}: computed usage cost`);
}
await fs.mkdir('/etc/sausage/roster');
await fs.write('/etc/sausage/roster/main.md','---\ndescription: Main\nmodel: responses/gpt-6.1-sol\n---\nMain role');
const registry=createRegistry();
registry.install(modelExtension({fs,models:()=>models,implement:()=>{},want:()=>undefined}));
const storage=await openStorage(globalThis.storagePath);
const harness=await Harness.open(storage,{models,registry,env:()=>environment('/'),settings:{retry:{enabled:false},get extensions(){return registry.snapshot().installed()}}},context);
const root=await harness.root(context,{agent:{cwd:'/',model:{provider:'responses',modelId:'gpt-6.1-sol'}}});
harness.resume();
try{
await (await root.submit({type:'input',content:'First run'},context)).wait(context);
config.providers.responses.base_url=globalThis.providerBase+'/edited-api';
config.providers.responses.api_key='next-key';
config.providers.responses.models=[{id:'gpt-6.1-sol',context_window:272000}];
await fs.write(CONFIG,JSON.stringify(config));
await (await root.submit({type:'input',content:'Next run'},context)).wait(context);
assert(models.getModel('responses','gpt-6.1-sol').contextWindow===272000,'provider metadata edit applies on the next run');
const entries=(await root.context(context)).entries.filter(entry=>entry.kind==='pi.assistant');
assert(entries.length===2&&entries.every(entry=>entry.model[0].usage.cost.total>0),'stored assistant usage includes call costs');
}finally{await harness.close(context);await storage.close()}
config.providers.responses.api_key='';await fs.write(CONFIG,JSON.stringify(config));
failure='';try{await models.getAuth('responses')}catch(error){failure=String(error)}
assert(failure.includes(CONFIG),'missing key identifies config file');

Some files were not shown because too many files have changed in this diff Show More