Skip to content

Commit 963aa07

Browse files
authored
Merge pull request #3 from SmartAI/codex/audit-pythonic-code-architecture
Code refactor
2 parents 8a46efe + b05f0dd commit 963aa07

24 files changed

Lines changed: 1323 additions & 843 deletions

‎.github/workflows/ci.yml‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [main]
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: astral-sh/setup-uv@v6
14+
with:
15+
python-version: "3.12"
16+
enable-cache: true
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: 22
20+
cache: npm
21+
- run: uv sync --frozen
22+
- run: uv run ruff check src tests
23+
- run: uv run mypy src
24+
- run: uv run pytest -q
25+
- run: npm ci
26+
- run: npm run check

‎README.md‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
A coding-agent harness in Python: a durable, replayable session log, a `drive → turn → step` loop
44
with a two-target inbox, four bounded tools, Anthropic and OpenAI-compatible providers, and a
5-
loopback Web UI. It is a port of [ava-cpp](../ava-cpp)'s architecture; the design documents there
6-
(`docs/agent-loop.md`, `docs/session-log.md`, `docs/model-layer.md`, `docs/functional-spec.md`)
7-
remain the specification this code implements.
5+
loopback Web UI. It is a port of ava-cpp's architecture; the local
6+
[architecture notes](docs/architecture.md) describe the boundaries and invariants this code
7+
implements.
88

99
## The shape
1010

@@ -30,10 +30,10 @@ from pathlib import Path
3030
from ava.agent import Agent
3131
from ava.llm import Item, Role, make_text_block, provider_from_environment
3232

33-
agent = Agent.create(provider_from_environment(), Path.cwd())
34-
subscription = agent.subscribe(lambda event: print(event.seq, type(event.payload).__name__))
35-
await agent.followup(Item(role=Role.user, blocks=[make_text_block("add a test for the parser")]))
36-
await agent.drive()
33+
async with Agent.create(provider_from_environment(), Path.cwd()) as agent:
34+
subscription = agent.subscribe(lambda event: print(event.seq, type(event.payload).__name__))
35+
await agent.followup(Item(role=Role.user, blocks=[make_text_block("add a test for the parser")]))
36+
await agent.drive()
3737
```
3838

3939
Every model-visible fact is a durable event. The model's context, the browser's transcript, and
@@ -124,6 +124,7 @@ The checked-in browser bundle is rebuilt with `npm ci && npm run build` when tha
124124
```sh
125125
uv run pytest
126126
uv run ruff check src tests
127+
uv run mypy src
127128
npm run check
128129
```
129130

‎docs/architecture.md‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Architecture
2+
3+
Ava has a headless core and two thin application surfaces: the CLI and the loopback Web UI.
4+
Dependencies point inward; provider wire formats and frontend concerns do not enter the agent or
5+
session packages.
6+
7+
```text
8+
app ──► agent ──► session ──► llm ──► transport
9+
│ │ │
10+
└──► tool ──┴──► proc └──► base
11+
```
12+
13+
## Public seam
14+
15+
`Agent` is the application boundary. Applications submit input, control the driver, subscribe to
16+
durable events, and read provider-neutral status or context reports through that object. Access to
17+
`Agent.state` is reserved for agent internals and invariant-focused tests.
18+
19+
An agent owns its provider, durable writer, and scratchpad. Call `await agent.aclose()` when done,
20+
or use `async with`. The Web registry closes every chat agent during the FastAPI lifespan shutdown.
21+
22+
## Durable input seam
23+
24+
Acknowledging input, claiming input for a step, and deciding a turn boundary are serialized by one
25+
inbox gate. `followup()` and `steer()` return only after their inbox splice is durable. A step claims
26+
one next-turn message on its first step and all available next-step messages. The gate remains held
27+
from the driver's snapshot through that claim so an acknowledged message cannot be lost between
28+
the two operations.
29+
30+
## Driver lifecycle
31+
32+
The driver moves through `idle`, `running`, `pausing`, `paused`, and `aborting`. A turn contains one
33+
or more provider steps; a step either ends the turn or emits tool calls whose paired results become
34+
the next step's context. Pause is honored at a complete step boundary. Abort repairs partial model
35+
and tool output before it closes the turn.
36+
37+
Only one driver may own an agent. The Web `DriveHandoff` compensates for a message acknowledged as
38+
the owning driver finishes, without starting a second driver.
39+
40+
## Session authority
41+
42+
The append-only session event stream is the authority for model context, browser replay, pending
43+
inbox state, and crash recovery. Physical writes are bounded frames; subscribers see durable events
44+
only after their bytes reach the kernel. Recovery may repair a torn final frame and append missing
45+
`interrupted` lifecycle closers, but never rewrites a complete logical event.
46+
47+
The JSONL codec is intentionally explicit. Its event-by-event mapping is compatibility and
48+
validation code, not generic object serialization, and unknown future event kinds are preserved.
49+
50+
## Evaluation gates
51+
52+
Every architectural change must keep these gates green:
53+
54+
- durable input is acknowledged and claimed exactly once;
55+
- every model tool call has one result after completion, abort, or recovery;
56+
- pause/resume and provider failure leave replayable history;
57+
- `ruff`, `mypy`, the Python 3.12 test suite, and the frontend build pass;
58+
- application code has no direct `agent.state` access;
59+
- provider clients close on agent replacement and shutdown.

‎pyproject.toml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ dependencies = [
99
"fastapi>=0.115",
1010
"uvicorn>=0.30",
1111
"httpx>=0.27",
12+
"pydantic>=2.10",
1213
"zstandard>=0.22",
1314
]
1415

@@ -24,6 +25,7 @@ packages = ["src/ava"]
2425

2526
[dependency-groups]
2627
dev = [
28+
"mypy>=1.15",
2729
"pytest>=8",
2830
"pytest-asyncio>=0.24",
2931
"pytest-timeout>=2.4.0",
@@ -41,3 +43,11 @@ target-version = "py312"
4143
[tool.ruff.lint]
4244
select = ["E", "F", "I", "UP", "B"]
4345
ignore = ["E501"]
46+
47+
[tool.mypy]
48+
python_version = "3.12"
49+
check_untyped_defs = true
50+
no_implicit_optional = true
51+
warn_redundant_casts = true
52+
warn_return_any = true
53+
warn_unused_ignores = true

‎src/ava/agent/agent.py‎

Lines changed: 118 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
sort_model_ids,
3737
)
3838
from ava.session import (
39+
AttemptTiming,
3940
DriveError,
4041
EventSink,
4142
InboxMessage,
@@ -45,8 +46,11 @@
4546
TurnEnd,
4647
TurnEndReason,
4748
TurnStart,
49+
Usage,
4850
)
4951
from ava.session.codec import validate_step_claimed_record
52+
from ava.session.compaction import estimate_context_tokens
53+
from ava.session.context_report import ContextReport, context_report
5054
from ava.session.log import Log, OpenMode
5155

5256

@@ -107,8 +111,21 @@ def reopen(
107111
agent._state.drive_state.restore_paused()
108112
return agent
109113

110-
def close(self) -> None:
111-
self._state.close()
114+
async def aclose(self) -> None:
115+
"""Close the provider and durable state. The agent must be idle."""
116+
state = self._state
117+
if state.drive_state.status not in (Status.idle, Status.paused):
118+
raise AvaError(ErrorKind.invalid_argument, "cannot close the agent while it is running")
119+
try:
120+
await state.provider.aclose()
121+
finally:
122+
state.close()
123+
124+
async def __aenter__(self) -> Agent:
125+
return self
126+
127+
async def __aexit__(self, *_: object) -> None:
128+
await self.aclose()
112129

113130
# ---- observation ----------------------------------------------------------------------
114131

@@ -224,7 +241,7 @@ async def model_choices(self) -> ModelChoices:
224241
available = False
225242
finally:
226243
state.model_catalog_operations -= 1
227-
current = state.pending_model or state.provider.selection.model
244+
current = self.current_selection().model
228245
models = [*listed, current, *state.provider.model_aliases.values()]
229246
sort_model_ids(models)
230247
deduplicated = list(dict.fromkeys(models))
@@ -235,21 +252,94 @@ async def model_choices(self) -> ModelChoices:
235252
def select_model(self, model: str) -> None:
236253
if not model:
237254
raise AvaError(ErrorKind.invalid_argument, "model id must not be empty")
238-
self._state.pending_model = model
239-
self._state.pending_effort = None
255+
current = self.current_selection()
256+
self._state.pending_selection = Selection(current.provider, model, None)
240257
self._state.model_revision += 1
241258

242259
def current_selection(self) -> Selection:
243-
provider = self._state.provider
244-
selection = Selection(
245-
provider.selection.provider, provider.selection.model, provider.selection.effort
260+
pending = self._state.pending_selection
261+
selected = pending or self._state.provider.selection
262+
return Selection(selected.provider, selected.model, selected.effort)
263+
264+
def current_capabilities(self) -> ModelCapabilities:
265+
"""Capabilities for the pending or active model selection."""
266+
return self._state.provider.capabilities(self.current_selection().model)
267+
268+
def context_report(self, *, prepare: bool = False) -> ContextReport:
269+
"""Describe the provider-neutral context without exposing runtime state."""
270+
if prepare:
271+
self.prepare()
272+
state = self._state
273+
return context_report(
274+
state.session,
275+
state.provider.context_window,
276+
state.compaction_options.threshold_percent,
277+
)
278+
279+
def status_snapshot(self) -> dict[str, object]:
280+
"""Return the stable frontend status contract from agent-owned state."""
281+
state = self._state
282+
selected = self.current_selection()
283+
report = self.context_report()
284+
used_tokens = (
285+
report.estimated_tokens
286+
if report.measured_input_tokens is None
287+
else estimate_context_tokens(state.session)
288+
)
289+
capabilities = state.provider.capabilities(selected.model)
290+
context_window = capabilities.context_window_tokens
291+
if context_window is None and selected.model == state.provider.selection.model:
292+
context_window = state.provider.context_window
293+
context_window = context_window or 0
294+
remaining = (
295+
round(max(0, context_window - used_tokens) * 100 / context_window)
296+
if context_window
297+
else None
298+
)
299+
300+
input_tokens: int | None = None
301+
output_tokens: int | None = None
302+
cached_read = 0
303+
cache_reported = False
304+
ttft_ms: int | None = None
305+
for event in state.session.events:
306+
match event.payload:
307+
case Usage(
308+
input=input,
309+
cached_read=cached_read_tokens,
310+
cache_write=cache_write,
311+
output=output,
312+
):
313+
input_parts = (input, cached_read_tokens, cache_write)
314+
if any(value is not None for value in input_parts):
315+
input_tokens = (input_tokens or 0) + sum(value or 0 for value in input_parts)
316+
if output is not None:
317+
output_tokens = (output_tokens or 0) + output
318+
if cached_read_tokens is not None:
319+
cached_read += cached_read_tokens
320+
cache_reported = True
321+
case AttemptTiming(ttft_ms=latest_ttft):
322+
# The newest attempt describes the latency the user most recently felt.
323+
ttft_ms = latest_ttft
324+
325+
cache_hit_percent = (
326+
cached_read * 100 // input_tokens if cache_reported and input_tokens else None
246327
)
247-
if self._state.pending_model is not None:
248-
selection.model = self._state.pending_model
249-
selection.effort = None
250-
if self._state.pending_effort is not None:
251-
selection.effort = self._state.pending_effort
252-
return selection
328+
return {
329+
"status": self.status.value,
330+
"turn_open": self.turn_open,
331+
"cwd": str(self.cwd),
332+
"provider": selected.provider,
333+
"model": selected.model,
334+
"effort": selected.effort,
335+
"context_used_tokens": used_tokens,
336+
"context_window_tokens": context_window,
337+
"context_remaining_percent": remaining,
338+
"input_tokens": input_tokens,
339+
"output_tokens": output_tokens,
340+
"cache_hit_percent": cache_hit_percent,
341+
"ttft_ms": ttft_ms,
342+
}
253343

254344
async def cycle_effort(self) -> str:
255345
state = self._state
@@ -280,7 +370,7 @@ async def cycle_effort(self) -> str:
280370
except ValueError:
281371
index = -1
282372
next_effort = efforts[0] if index < 0 or index + 1 >= len(efforts) else efforts[index + 1]
283-
state.pending_effort = next_effort
373+
state.pending_selection = Selection(selected.provider, selected.model, next_effort)
284374
return next_effort
285375

286376
def select_effort(self, effort: str | None) -> None:
@@ -295,9 +385,10 @@ def select_effort(self, effort: str | None) -> None:
295385
f"the current model does not advertise reasoning effort '{effort}'; "
296386
f"choose one of {', '.join(capabilities.effort_values) or 'none'}",
297387
)
298-
self._state.pending_effort = effort
388+
selected = self.current_selection()
389+
self._state.pending_selection = Selection(selected.provider, selected.model, effort)
299390

300-
def reload_credentials(
391+
async def reload_credentials(
301392
self, auth_requirement: AuthRequirement = AuthRequirement.required
302393
) -> None:
303394
state = self._state
@@ -307,7 +398,11 @@ def reload_credentials(
307398
or state.model_catalog_operations
308399
):
309400
raise AvaError(ErrorKind.invalid_argument, "cannot reload credentials while busy")
310-
state.provider = provider_from_environment(None, state.provider.selection, auth_requirement)
401+
old_provider = state.provider
402+
new_provider = provider_from_environment(None, old_provider.selection, auth_requirement)
403+
state.provider = new_provider
404+
if new_provider is not old_provider:
405+
await old_provider.aclose()
311406

312407
# ---- the driver ----------------------------------------------------------------------
313408

@@ -410,13 +505,15 @@ async def drive(self) -> None:
410505
owns = False
411506
return
412507
if not drive.close_turn():
413-
error = AvaError(
508+
close_error = AvaError(
414509
ErrorKind.internal,
415510
"cannot close a turn while tool results remain outstanding",
416511
)
417-
await self._finish_failed_turn(turn_number, TurnEndReason.completed, error)
512+
await self._finish_failed_turn(
513+
turn_number, TurnEndReason.completed, close_error
514+
)
418515
owns = False
419-
raise error
516+
raise close_error
420517
elapsed_ms = int((time.monotonic() - turn_started) * 1000)
421518
state.append(
422519
TurnEnd(

‎src/ava/agent/state.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,7 @@ class AgentState:
329329
maintenance_running: bool = False
330330
ordinals_recovered: bool = False
331331
initialized: bool = False
332-
pending_model: str | None = None
333-
pending_effort: str | None = None
332+
pending_selection: Selection | None = None
334333

335334
@classmethod
336335
def create(

0 commit comments

Comments
 (0)