Skip to content

Commit fc00f3c

Browse files
martex-devclaude
andcommitted
M2: missions, dependencies, and three agents working together
A company-scale objective now decomposes into projects and tasks, and agents work from each other's output without anything orchestrating them. INTEL briefs the crypto desk from measured bars | (task dependency) QUANT checks that briefing against a window it measured itself, and raises a research question | (task dependency) LEAD-R decides whether the question earns a project No orchestrator --------------- Sequencing is a task dependency, not a component. A task whose dependency has not succeeded is invisible to `claim`, so nothing polls and no scheduler process has to be alive for a chain to progress. The dependency IS the plan, it lives in the database, and a company switched off overnight resumes exactly where it stopped. The counterpart matters as much: a task whose dependency terminally failed is CANCELLED, with the reason naming the upstream. A chain that silently stalls forever is indistinguishable from a chain nobody started. That path fired for real during development -- the question step failed, and triage was correctly cancelled rather than left waiting. Two gates, enforced on the transition ------------------------------------- A mission cannot leave PLANNING without a kickoff, and cannot reach CLOSED without a retrospective. Meeting at the start and the end is a property of the state machine rather than a habit somebody remembers. At M2 an operator writes the kickoff; at M3 a Kickoff *meeting* produces the same artifact. The gate does not change -- only who can satisfy it -- and `Kickoff.kind` records which, so "was this planned by the company or by a human in a hurry?" stays answerable years later. An empty plan is refused: a blank one would satisfy the gate without doing the thing the gate exists for. Progress is computed, and reports its failures ---------------------------------------------- `Progress` carries succeeded, failed, refused-for-budget and cancelled separately. There is deliberately no method that returns a single reassuring percentage, and a retrospective records the outcome counts as they actually were -- a retrospective that kept only the successes would be the graveyard being quietly emptied. The budget envelope travels with the task ----------------------------------------- Spend was being attributed to the agent's day but not to the project that commissioned the work, so mission totals read zero. The envelope checked at dispatch is now stored on the task and merged with the agent's daily allowance when it runs. Storing rather than re-deriving is deliberate: the envelope that was CHECKED is the one the spend belongs to, and re-deriving it later would attribute work to whatever hierarchy exists then, quietly moving historical totals. It also keeps the worker from having to know what a mission is. One validator, one implementation --------------------------------- The rule that an agent may only state figures it was shown was implemented three times, and two of those validated against a different structure than the one rendered into the prompt. That fails in the worst direction: rejecting honest output while letting invented figures through whenever the two structures diverge. It now lives in `agents/interpret.py`, which renders and validates from the same object so they cannot drift. Two charter corrections ----------------------- The permission model refused QUANT's turn, correctly, and the refusal exposed a real gap: a quant researcher had neither sight of the desk's observations nor access to price data. Research starts from what Intelligence observed, and this is not a shortcut past any work -- which is the test a view grant has to pass. `research.quant` gains DESK_OBSERVATIONS, DESK_MARKET_SNAPSHOT and DATA_OHLCV; `research.lead` gains DESK_OBSERVATIONS so it can see the evidence its team argues from. The working day --------------- Standing jobs are registered by name, idempotently, and can now address the task they fire to a specific agent -- a scheduled task addressed to nobody would sit in the queue looking like work in progress, and one addressed to an unhired agent is skipped rather than registered against a ghost. Only the daily crypto briefing is registered. Queue housekeeping is deliberately not a standing job: it is platform maintenance rather than something an agent does, and dressing it up as agent work would put a task in the queue that no charter is responsible for. 240 tests, ruff clean, mypy strict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7be533b commit fc00f3c

24 files changed

Lines changed: 2668 additions & 108 deletions

File tree

README.md

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ themselves as the evidence justifies it.
1111
[![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)
1212
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
1313

14-
Status: **M1 complete — the company is staffed and working.** 76 charters,
15-
17 agents, permissions enforced by the database. · 2026-09-04
14+
Status: **M2 complete — missions decompose and agents collaborate.** 76
15+
charters, 17 agents, three-agent chains that sequence themselves. · 2026-09-04
1616

1717
> Research software. No live trading adapter exists. Nothing here is proven
1818
> profitable. Read [DISCLAIMER.md](DISCLAIMER.md).
@@ -25,27 +25,31 @@ Status: **M1 complete — the company is staffed and working.** 76 charters,
2525
pip install -e ".[dev]"
2626
aurelis db init # schema, invariants, the org chart
2727
aurelis agent hire # staff the launch roster
28-
aurelis run # give the company a turn
28+
aurelis mission run # open a mission and work it to completion
2929
aurelis doctor
3030
```
3131

32-
`run` gives an analyst a turn: it builds a view its charters permit, calls
33-
tools for every number, asks a model to *interpret* those numbers, records a
34-
bitemporal observation with the digest of the data it came from, and posts a
35-
briefing citing its evidence. It costs nothing — the mock provider is offline
36-
and deterministic, which is how a hundred-agent company gets built before a
37-
single token is spent.
32+
`mission run` opens a mission, records the kickoff that unlocks it, plans a
33+
project into three dependent tasks, and runs them. **INTEL** briefs the crypto
34+
desk from measured bars. **QUANT** reads that briefing, checks it against an
35+
independent window it measured itself, and raises a research question.
36+
**LEAD-R** decides whether the question earns a project. Each waits for the one
37+
before because the queue will not hand out a task whose dependency has not
38+
succeeded — there is no orchestrator.
3839

3940
```
40-
turns 1
41-
observations 1
42-
tokens 291
41+
mission MSN-0001
42+
turns 3
43+
progress 3/3 succeeded
44+
tokens 866
4345
cost $0.000000
44-
chain chain verified: 51 events, seq 1..51
46+
chain chain verified: 83 events, seq 1..83
4547
```
4648

4749
Look around: `aurelis org show` · `aurelis org desks` · `aurelis agent list` ·
48-
`aurelis agent show INTEL` (what one agent holds, sees, writes and may invoke).
50+
`aurelis agent show INTEL` (what one agent holds, sees, writes and may invoke)
51+
· `aurelis mission show MSN-0001` (every task, its status, what it waits on) ·
52+
`aurelis tick` (advance the working day one turn).
4953

5054
---
5155

@@ -240,7 +244,7 @@ automatically by the company, five milestones in.
240244
|---|---|---|
241245
| **M0**| Foundations | ledger, budgets, artifacts, queue, provider abstraction |
242246
| **M1**| Agent runtime | 76 charters, 17 agents, permissions, views, tools, the loop |
243-
| **M2** | Missions | missions → projects → tasks, scheduler |
247+
| **M2** | Missions | missions → projects → tasks, dependencies, the working day |
244248
| **M3** | **Meetings** | the seven-phase protocol; the company becomes a company |
245249
| **M4** | Research lifecycle | engines, preregistration, experiments, findings |
246250
| **M5** | Critique & audit | objections with tests, the H71 reproduction |

docs/07-roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ when exhausted.
4343

4444
---
4545

46-
## M2 — Missions, projects, tasks
46+
## M2 — Missions, projects, tasks
4747

4848
- `missions/` — the three-level hierarchy, assignment, dependencies, progress,
4949
budget splits.

src/aurelis/agents/interpret.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""Asking a model to interpret, and checking that it only did that.
2+
3+
One function, because the rule it enforces is the one that separates a
4+
research organization from a very articulate opinion generator:
5+
6+
**An agent may only state figures it was shown.**
7+
8+
The subtlety that makes this worth centralising: the set of permitted numerals
9+
must be derived from *exactly* the material that was rendered into the prompt.
10+
Building the prompt from one structure and validating against another is an
11+
easy mistake — it was made twice while writing M2 — and it fails in the worst
12+
possible direction, rejecting honest output while letting invented figures
13+
through whenever the two structures happen to diverge.
14+
15+
Here the material is rendered and validated from the same object, so the two
16+
cannot drift.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import re
22+
from typing import TYPE_CHECKING, Any
23+
24+
from aurelis.core.enums import ModelTier
25+
from aurelis.platform.budget.ledger import Spend
26+
from aurelis.platform.llm.types import LlmRequest, LlmResponse, Message, ModelRef
27+
28+
if TYPE_CHECKING:
29+
from aurelis.agents.loop import AgentContext
30+
31+
__all__ = [
32+
"Interpretation",
33+
"UnsourcedFigures",
34+
"allowed_figures",
35+
"interpret",
36+
"render_material",
37+
"unsourced_numerals",
38+
]
39+
40+
#: Numerals that may appear in prose without being a claim about the data.
41+
#: Deliberately tiny: small counts and ordinals are unavoidable in English
42+
#: ("two things stand out"), and everything else must be sourced.
43+
_FREE_NUMERALS = frozenset({"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"})
44+
45+
_NUMERAL = re.compile(r"-?\d+(?:[.,]\d+)*%?")
46+
47+
48+
def unsourced_numerals(text: str, allowed: set[str]) -> list[str]:
49+
"""Numerals in ``text`` that do not appear in the measurements.
50+
51+
The check is deliberately literal: a figure is either one the tools
52+
produced or it is not. Matching "approximately" would defeat the purpose,
53+
since a model that rounds 1.47 to 1.5 has stated a number nothing
54+
supports.
55+
"""
56+
found: list[str] = []
57+
for match in _NUMERAL.finditer(text):
58+
token = match.group(0)
59+
bare = token.rstrip("%").replace(",", "")
60+
if bare in _FREE_NUMERALS or bare in allowed:
61+
continue
62+
# Tolerate a trailing zero difference: "0.50" cites "0.5".
63+
if bare.rstrip("0").rstrip(".") in {a.rstrip("0").rstrip(".") for a in allowed}:
64+
continue
65+
found.append(token)
66+
return found
67+
68+
69+
def allowed_figures(*payloads: dict[str, Any]) -> set[str]:
70+
"""Every numeric token the agent was actually shown."""
71+
allowed: set[str] = set()
72+
73+
def walk(value: Any) -> None:
74+
if isinstance(value, dict):
75+
for item in value.values():
76+
walk(item)
77+
elif isinstance(value, list):
78+
for item in value:
79+
walk(item)
80+
elif isinstance(value, str):
81+
for match in _NUMERAL.finditer(value):
82+
allowed.add(match.group(0).rstrip("%").replace(",", ""))
83+
elif isinstance(value, (int, float)):
84+
allowed.add(str(value))
85+
86+
for payload in payloads:
87+
walk(payload)
88+
return allowed
89+
90+
91+
92+
class UnsourcedFigures(ValueError):
93+
"""The model stated a number nothing it was shown supports."""
94+
95+
def __init__(self, invented: list[str], shown: int) -> None:
96+
super().__init__(
97+
f"output cites {len(invented)} figure(s) not present in the "
98+
f"{shown} value(s) supplied: {', '.join(invented[:5])}. "
99+
"Agents interpret; software computes."
100+
)
101+
self.invented = invented
102+
103+
104+
class Interpretation:
105+
"""A validated model response and what it cost."""
106+
107+
__slots__ = ("response", "text")
108+
109+
def __init__(self, response: LlmResponse) -> None:
110+
self.response = response
111+
self.text = response.text.strip()
112+
113+
@property
114+
def spend(self) -> Spend:
115+
return Spend(self.response.usd, self.response.usage.total)
116+
117+
118+
def render_material(material: dict[str, Any]) -> str:
119+
"""Render the material an agent is being shown, deterministically."""
120+
lines: list[str] = []
121+
for section, payload in material.items():
122+
lines.append(f"{section.replace('_', ' ').title()}:")
123+
if isinstance(payload, dict):
124+
lines += [f" {key}: {value}" for key, value in sorted(payload.items())]
125+
elif isinstance(payload, list):
126+
lines += [f" - {item}" for item in payload]
127+
else:
128+
lines.append(f" {payload}")
129+
lines.append("")
130+
return "\n".join(lines).strip()
131+
132+
133+
def interpret(
134+
context: AgentContext,
135+
*,
136+
system: str,
137+
material: dict[str, Any],
138+
tier: ModelTier = ModelTier.MID,
139+
max_tokens: int = 400,
140+
) -> Interpretation:
141+
"""Ask the model to interpret ``material``, and refuse anything else.
142+
143+
Raises :class:`UnsourcedFigures` if the response contains a numeral that
144+
does not appear in ``material``. The turn then fails and the reason is
145+
recorded against the agent, which is the outcome an Agent Behavior Auditor
146+
needs to be able to sample for.
147+
"""
148+
response = context.provider.complete(
149+
context.session,
150+
LlmRequest(
151+
model=ModelRef(
152+
provider=context.provider.name,
153+
model=str(context.task.payload.get("model", "mock-1")),
154+
tier=tier,
155+
max_tokens=max_tokens,
156+
),
157+
system=system,
158+
messages=(Message("user", render_material(material)),),
159+
actor=context.agent.ref,
160+
task_ref=context.task.ref,
161+
),
162+
)
163+
164+
permitted = allowed_figures(material)
165+
invented = unsourced_numerals(response.text, permitted)
166+
if invented:
167+
raise UnsourcedFigures(invented, len(permitted))
168+
return Interpretation(response)

src/aurelis/agents/loop.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,12 @@ def run_once(
233233
)
234234
return None
235235

236-
envelope = self.envelope_for(agent, at=moment)
236+
# The envelope the task was dispatched under, plus this agent's own
237+
# daily allowance. Both bind: a project can exhaust itself, and so can
238+
# an agent that has had a busy day inside a healthy project.
239+
envelope = self.envelope_for(agent, at=moment).merge(
240+
BudgetEnvelope.from_scopes(task.budget_envelope or {})
241+
)
237242
allowance = Spend(
238243
Decimal(task.allowance_usd or 0), int(task.allowance_tokens or 0)
239244
)

src/aurelis/cli/main.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@
2222
from rich.table import Table
2323

2424
import aurelis.intel.briefing # noqa: F401 -- registers the briefing handler
25+
import aurelis.research.triage # noqa: F401 -- registers question and triage
2526
from aurelis import __version__
2627
from aurelis.cli.company import agent_app, org_app
2728
from aurelis.cli.demo import run_demo
2829
from aurelis.cli.doctor import Status, run_checks
30+
from aurelis.cli.mission import mission_app
2931
from aurelis.core.config import load_settings
3032
from aurelis.runtime import Runtime
3133

@@ -41,6 +43,7 @@
4143
app.add_typer(ledger_app, name="ledger")
4244
app.add_typer(org_app, name="org")
4345
app.add_typer(agent_app, name="agent")
46+
app.add_typer(mission_app, name="mission")
4447

4548
def _force_utf8() -> None:
4649
"""Make the console safe for arbitrary text.
@@ -352,5 +355,68 @@ def run(
352355
console.print(table)
353356

354357

358+
@app.command()
359+
def tick(
360+
workspace: WorkspaceOption = None,
361+
rounds: Annotated[int, typer.Option(help="How many passes to make.")] = 1,
362+
) -> None:
363+
"""Advance the company's working day by one turn.
364+
365+
Fires any due scheduled jobs, clears work stranded behind a dependency
366+
that can never succeed, then gives every active agent a chance to act.
367+
Nothing here decides what to do — the schedule and the dependency graph do.
368+
"""
369+
from aurelis.missions.schedule import register_standing_jobs
370+
371+
runtime = _runtime(workspace)
372+
try:
373+
runtime.initialise()
374+
runtime.staff()
375+
376+
fired: list[str] = []
377+
stranded = 0
378+
turns = []
379+
for _ in range(rounds):
380+
with runtime.database.session() as session:
381+
register_standing_jobs(session, runtime.scheduler, runtime.roster)
382+
fired += [t.ref for t in runtime.scheduler.tick(session)]
383+
stranded += len(runtime.queue.cancel_stranded(session))
384+
for agent in runtime.roster.workable(session):
385+
runtime.worker.open_daily_budget(session, agent, tokens=50_000)
386+
result = runtime.worker.run_once(session, agent)
387+
if result is not None:
388+
turns.append(result)
389+
390+
with runtime.database.session() as session:
391+
queued = runtime.queue.depth(session)
392+
counts = runtime.queue.counts_by_status(session)
393+
verification = runtime.ledger.verify(session)
394+
finally:
395+
runtime.close()
396+
397+
table = Table(show_header=False, box=None)
398+
table.add_column("", style="bold", width=16)
399+
table.add_column("")
400+
table.add_row("jobs fired", str(len(fired)))
401+
table.add_row("turns worked", str(len(turns)))
402+
if stranded:
403+
table.add_row("stranded", f"[yellow]{stranded} cancelled[/yellow]")
404+
table.add_row("queue", ", ".join(f"{k}={v}" for k, v in sorted(counts.items())) or "empty")
405+
table.add_row("waiting", str(queued))
406+
table.add_row(
407+
"chain",
408+
f"[green]{verification.describe()}[/green]"
409+
if verification.ok
410+
else f"[red]{verification.describe()}[/red]",
411+
)
412+
console.print(table)
413+
414+
for result in turns:
415+
console.print(f" [dim]·[/dim] {escape(result.summary)}")
416+
417+
if not verification.ok:
418+
raise typer.Exit(code=1)
419+
420+
355421
if __name__ == "__main__": # pragma: no cover
356422
app()

0 commit comments

Comments
 (0)