diff --git a/docs/plan/M2.md b/docs/plan/M2.md new file mode 100644 index 0000000..43c9a04 --- /dev/null +++ b/docs/plan/M2.md @@ -0,0 +1,86 @@ +# Milestone M2: domain and loop + +- Status: In progress — `chat` mode, the loop-to-`RunBloc` application service, and the section 2.8 metrics remain. +- Specification: `docs/spec/domain.md` (sections 8–9), `docs/spec/storage.md` (section 15), `docs/spec/roadmap.md` (M2), `docs/spec/dart-language.md` (section 7) +- ADR: — (no decision changed; the discrepancies found during implementation are recorded as Q7–Q14 in `docs/spec/open-questions.md`) + +## Acceptance criteria + +- [x] `Run.addTaint` adds a label idempotently; the `taints` getter is a read-only view, the constructor copies its input, and the run never removes a label (`runtime/core/test/domain/run_test.dart`). +- [x] `HiveJournal` implements `JournalPort`: the `seq` grows across calls and reopen, and `forRun` filters by run (`runtime/core/test/infrastructure/hive_journal_test.dart`). +- [x] `runAgent` follows the fixed section 9 order: malformed and unavailable checks, policy, approval, journal `started` before the tool call, timeout with a per-invocation child token, taint on untrusted output, non-throwing truncation, fresh tool snapshot per step, cancellation; 24 tests (`runtime/core/test/application/agent_loop_test.dart`). +- [x] `truncateToolOutput` measures UTF-8 bytes, cuts on a rune boundary, and JSON-encodes non-strings (`runtime/core/test/application/tool_output_test.dart`); `ToolSet` matches on the LLM name (`runtime/core/test/application/tool_set_test.dart`). +- [x] Fail-closed agent defaults are registered in `CoreModule`; `registerJournal({required String directory})` registers the journal after `configureCore` and rejects a second registration; `AgentDeps` resolves from the registered collaborators (`runtime/core/test/di/injection_test.dart`). +- [x] `dart run melos run ci` is green (format, analyze, arch check, tests) and `dart run tools/docs_check/bin/docs_check.dart` exits 0. +- [ ] `chat` mode runs end-to-end against a fake `LlmPort` (roadmap M2). +- [ ] The application service binds the loop to `RunBloc`, projects journal state, and publishes `AgentEvent`s through `StatePublisher` (sections 8.10 and 8.11). +- [ ] The section 2.8 target metrics are measured (not measurable before the daemon exists; postponed to M4). + +## Steps + +- [x] Step 1: `Run` aggregate — `RunMode`, `Run`, a read-only `taints` view over copied labels, `Run.addTaint` in `runtime/core/lib/src/domain/run.dart`, exported from `package:alteri_one_core`; tests in `runtime/core/test/domain/run_test.dart`. +- [x] Step 2: Hive-backed journal — `runtime/core/lib/src/infrastructure/journal/hive_journal.dart`, implementing `started`, `denied`, `finished`, and `forRun` with a shared sequence counter; tests in `runtime/core/test/infrastructure/hive_journal_test.dart`. The constructor uses `new _` (fix for `unnecessary_type_name_in_constructor`). +- [x] Step 3 (added): application/agent seam types in `runtime/core/lib/src/application/agent/` — `RunResult` (`RunCompleted`, `RunBudgetExceeded`), `ToolSet` and `ToolRegistry`, `AgentConfig`, `ApprovalService`, `ContextBuilder`, `AgentDeps`, and internal `truncateToolOutput`; tests in `runtime/core/test/application/tool_set_test.dart` and `runtime/core/test/application/tool_output_test.dart`. +- [x] Step 4 (added): section 9 loop — `runAgent` in `runtime/core/lib/src/application/agent/agent_loop.dart`; 24 tests in `runtime/core/test/application/agent_loop_test.dart` with the fakes in `runtime/core/test/application/agent_fakes.dart`. +- [x] Step 5 (added): fail-closed defaults in `runtime/core/lib/src/application/agent/agent_defaults.dart` (`DefaultAgentConfig`, `DenyAllApprovals`, `PromptContextBuilder`, `EmptyToolRegistry`), `AgentDeps` binding in `runtime/core/lib/src/di/core_module.dart`, and `registerJournal({required String directory})` in `runtime/core/lib/src/di/injection.dart`; tests in `runtime/core/test/di/injection_test.dart`. +- [x] Step 6 (added): rewrite this plan in the `_template.md` form and record the M2 open questions (Q7–Q14) in `docs/spec/open-questions.md`. +- [ ] Step 7 (added): `chat` mode end-to-end with a fake `LlmPort`; a test drives a conversation through the loop and checks the final reply and the journal. Gated by Q13: the model-call journaling decision must land with this step, not be postponed again. +- [ ] Step 8 (added): application service that binds `runAgent` to `RunBloc`; it projects run state from the journal and publishes `AgentEvent`s through `StatePublisher` (sections 8.10, 8.11). Gated by Q13: the service journals model calls and transcript persistence. +- [ ] Step 9 (added): section 14 profile and risk configuration plus an interactive approval channel; replaces `DefaultAgentConfig` and `DenyAllApprovals`. Profile rules must never lower a declared risk. +- [ ] Step 10 (added): section 13 untrusted wrapping — 128-bit random boundary per run, `untrusted_` variables, the injection test with a `write` tool, and the tool-output boundary wrapping deferred from the loop (Q8). Until then `PromptContextBuilder` passes the prompt through unwrapped and tool output reaches the model as the truncated plain value. +- [ ] Step 11 (added): section 2.8 target metrics (cold start, idle memory, event latency p99, tool-call overhead). This needs the daemon and is therefore done in M4. + +## Open checks [VERIFY] + +- [ ] T2: schemantic annotations on SDK 3.13 — partially resolved; `ToolSpec` and the other DTOs stay hand-written, the spike remains for edge cases. +- [ ] T3: `returnToolRequests` with `genkit_openai` and `llama-server` — the loop relies on it; the adapter lands in M3. +- [ ] T4: `genkit_openai` behavior with invalid tool arguments — the loop marks such calls `malformed`; the adapter lands in M3. +- [ ] T5: `response_format` with JSON Schema on the `llama-server` build — environment gap, no server installed. +- [ ] T6: the complete `llama-server` tool and web-UI flag list — environment gap, no server installed. +- [ ] T8: handlebars_dart strict mode through dotprompt — affects the section 13 wrapping (Step 10). +- [ ] T10: the `SKILL.md` format in `genkit_middleware` (M5). +- [ ] T11: `dart build cli` on Dart 3.13 for each OS (M4). +- [ ] T13: the `shelf_io.serve` signature with `securityContext` (M4b). +- [ ] T15: `StatePublisher` queue limits and overflow behavior (M5) — relevant to Step 8. +- [ ] T16: `mcp_dart` client API and protocol version (M8). +- [ ] T17: `dart language-server` pull diagnostics and position encodings (M7). +- [ ] T18: context compaction keeps goals and open items (M6). +- [ ] T19: system key-storage libraries and Linux behavior without Secret Service. +- [ ] T20: Windows logon-task shutdown and `%APPDATA%` mapping (M4). +- [ ] Q7: mutable `Run` versus copy-with for taint labels — see `../spec/open-questions.md`. +- [ ] Q8: the exact `_truncateUntrusted` contract — see `../spec/open-questions.md`. +- [ ] Q9: types of `d.config`, `d.registry`, `d.context`, `d.approvals` — see `../spec/open-questions.md`. +- [ ] Q10: journal directory resolution versus a synchronous `configureCore` — see `../spec/open-questions.md`. +- [ ] Q11: enforcement of the budget beyond `maxSteps` — see `../spec/open-questions.md`. +- [ ] Q12: journaling an approval refusal — see `../spec/open-questions.md`. +- [ ] Q13 (gates Steps 7–8): model-call journaling and transcript persistence (`JournalPort` or application-service decorator) — see `../spec/open-questions.md`. +- [ ] Q14: `unknownOutcome` versus `failed` for a timed-out, uncancellable tool — see `../spec/open-questions.md`. + +## Outcome + +The domain and loop groundwork landed on `feat/m2-run-aggregate` (draft PR #41): the `Run` aggregate with a read-only `taints` view and `addTaint`, the Hive journal and its registration hook, the `application/agent/` seam types, the section 9 `runAgent` loop with per-invocation child tokens and non-throwing truncation, fail-closed defaults and their DI bindings. + +Postponed within M2: `chat` mode end-to-end, the application service that binds the loop to `RunBloc` and `StatePublisher`, section 14 configuration and the interactive approval channel, and section 13 untrusted prompt and tool-output wrapping. The section 2.8 metrics are postponed to M4 because there is no daemon to measure yet. + +Decisions the specification does not settle are recorded as Q7–Q14 in [open-questions.md](../spec/open-questions.md); `docs/spec/domain.md` gained only a pointer to Q7, Q8, and Q14, and no decision was changed. Q12 records the observed gap that an approval refusal is not journalled; Q13 records that the loop does not journal model calls, which section 8.10 rule 2 demands (gates plan Steps 7–8); Q14 records that a timed-out call is kept as `failed`. + +### Milestone history (M0, M1, M2 step 1) + +Recorded before the step 6 rewrite; kept as written. + +#### M0 — Done + +- Dev tools implemented: `tools/arch_check`, `tools/codegen_check`, `tools/docs_check`, `tools/signing`; CI green; tests pass; `melos run ci` green. +- PR #37 merged (Melos 8 upgrade, lock file removal, CI rewrite), and PR #39 merged (dev tools and M0 spikes). Open M0 checks: T5, T6, T16, T17, T19. + +#### M1 — Done + +- Core skeleton implemented: contracts, pure domain, domain ports, state components, `RunBloc` and friends, injectable composition root with fail-closed `PolicyGate`. +- `provider_api`, `sdk`, `client` remain scaffolds, to be filled in later milestones. +- PR #40 merged to mark M1 done. + +#### M2 step 1 — Done + +- Run aggregate added: `enum RunMode { chat, task, resident }` and `final class Run` in `runtime/core/lib/src/domain/run.dart`, exported from `package:alteri_one_core/alteri_one_core.dart`. +- Hive-backed `JournalPort` implementation added at `runtime/core/lib/src/infrastructure/journal/hive_journal.dart`. +- Unit tests added: `runtime/core/test/domain/run_test.dart` and `runtime/core/test/infrastructure/hive_journal_test.dart`. diff --git a/docs/spec/domain.md b/docs/spec/domain.md index 2030ed4..9a751e2 100644 --- a/docs/spec/domain.md +++ b/docs/spec/domain.md @@ -326,4 +326,6 @@ Future _invoke(Run run, ToolSet tools, ToolCall call, AgentDeps Tool outputs are always untrusted and are truncated to `maxOutputBytes`. The full error text is not passed to the model context, so internal details do not leak. +Implementation note: the following details of the pseudo-code above differ deliberately. `Run.addTaint` stands in for `run.taints.add` (Q7). Cancellation is re-checked after the model call and again after the tool batch, before a result is returned. Both versions truncate after the journal write; the difference is that taint application and truncation run outside the `try`, so neither can rewrite an already-written `succeeded` record (Q8). A timed-out call stays `failed` (Q14). See `open-questions.md`. + Generation through genkit: a `generate` call with `returnToolRequests: true` returns tool requests without executing them. Parameter names are checked against genkit 1.x during implementation [VERIFY]. The adapter in `alteri_one_provider_llama_cpp` converts the response into a `Turn`. A `ToolCall` with invalid arguments is marked `malformed` without failing the run. diff --git a/docs/spec/open-questions.md b/docs/spec/open-questions.md index e815b6e..87ac97c 100644 --- a/docs/spec/open-questions.md +++ b/docs/spec/open-questions.md @@ -4,7 +4,7 @@ ## 25.1 Questions for the owner -Added during the M0 scaffold (2026-10-17). Until answered, the scaffold takes the conservative choice listed under each question. +Added during the M0 scaffold (2026-10-17); Q7–Q14 added during M2 (2026-11-08). Until answered, the scaffold takes the conservative choice listed under each question. | ID | Question | Conservative choice taken | |---|---|---| @@ -14,6 +14,14 @@ Added during the M0 scaffold (2026-10-17). Until answered, the scaffold takes th | Q4 | ~~Repository URL: the spec gives the repository name `alteri-one` but not the GitHub owner.~~ Resolved 2026-10-17: the owner is `dip-develop`; all pubspecs and docs use `https://github.com/dip-develop/alteri-one`. | resolved | | Q5 | `public_member_api_docs` cannot coexist with the primary-constructor style mandated by section 7.2: the Dart 3.13.5 analyzer does not attach `///` comments to primary-constructor parameters (the style used by every example in `domain.md` section 8). Re-enable the lint when the analyzer supports parameter docs. Until then, parameter dartdoc stays mandatory by convention; `packages/AGENTS.md` ("public classes and methods have English dartdoc") is enforced in review, not by the analyzer. | lint omitted from the shared set; docs kept by convention | | Q6 | Section 7.3 shows named `new` constructors with a dot (`new origin()`); the Dart 3.13.5 compiler rejects the dot and requires a space (`new origin() : x = 0, y = 0;`), named factories are written without the class name (`factory standard() { ... }`), and named `new` factories are illegal. Update section 7.3 with the compiler-verified forms. | code follows the compiler forms probed on 3.13.5 | +| Q7 | Section 9 shows `run.taints.add(...)` (section 8.2 does not), but `Run` defaults to `const {}` and the spec does not say whether `Run` is a mutable aggregate or copy-with. Confirm the mutable form and that the run itself never clears a label. | read-only `taints` view plus `Run.addTaint`; only the owner clears a label | +| Q8 | `_truncateUntrusted` is unspecified: UTF-8 bytes or characters, JSON encoding for non-strings, a cut marker, a fallback for values `jsonEncode` rejects, and whether the section 13 boundary wrapper belongs in it. | UTF-8 bytes, rune-safe cut, `jsonEncode` for non-strings, a bounded `[unserializable output]` fallback, no marker or wrapper in M2 | +| Q9 | Section 9 uses `d.config`, `d.registry`, `d.context`, and `d.approvals` without giving their types. | minimal application-layer interfaces in `application/agent/`; no section 14 loading in M2 | +| Q10 | `configureCore` is synchronous and the journal needs a directory; section 18.3 path resolution is M4. Confirm who resolves the path. | `registerJournal(directory:)` after `configureCore`; the caller owns path and lifetime, no home-directory default | +| Q11 | Section 9 enforces only `maxSteps`, while `Budget` also has `maxTokens`, `maxDuration`, and `maxOutputTokens`; `Turn` carries no usage yet, so the loop cannot enforce them. | enforce `maxSteps` only; revisit when the provider reports usage (M3) | +| Q12 | An approval refusal is not journalled: section 9 writes `denied` only for policy denials, so `not_approved` leaves no audit record although it stops a side effect. Should a refusal be recorded? | keep the spec order, no extra record in M2; add an approval outcome record when the interactive channel lands (M4/M9) | +| Q13 | Section 8.10 rule 2 lists "model call, tool call, channel send" as effects that must be journalled before they run, but the section 9 loop calls `d.llm.complete(...)` without a journal record and the section 9 snippet omits one. Is model-call journaling and transcript persistence the `JournalPort`'s job, or a decorator in the application service? | out of the loop for M2; decided together with transcript persistence in plan Step 8 | +| Q14 | A timed-out tool cannot be cancelled by `Future.timeout`. Must a call that may still complete be recorded as `unknownOutcome` instead of `failed`? Section 9 writes `failed`. | keep `failed` (section 9); cancel a per-invocation child token and leave the run token alone | Previously none were open. Decisions are recorded in ADR-0005, ADR-0006, and ADR-0009. diff --git a/runtime/core/lib/alteri_one_core.dart b/runtime/core/lib/alteri_one_core.dart index 9583b08..5db55d1 100644 --- a/runtime/core/lib/alteri_one_core.dart +++ b/runtime/core/lib/alteri_one_core.dart @@ -11,6 +11,14 @@ library; // Application state (docs/spec/domain.md, section 8.10) and // composition; then the pure domain (sections 8.1–8.9). +export 'src/application/agent/agent_config.dart'; +export 'src/application/agent/agent_defaults.dart'; +export 'src/application/agent/agent_deps.dart'; +export 'src/application/agent/agent_loop.dart'; +export 'src/application/agent/approval_service.dart'; +export 'src/application/agent/context_builder.dart'; +export 'src/application/agent/run_result.dart'; +export 'src/application/agent/tool_set.dart'; export 'src/application/approvals_bloc.dart'; export 'src/application/bloc_defaults.dart'; export 'src/application/channels_cubit.dart'; @@ -32,5 +40,7 @@ export 'src/domain/ports/journal_port.dart'; export 'src/domain/ports/llm_port.dart'; export 'src/domain/ports/memory_port.dart'; export 'src/domain/ports/tool_port.dart'; +export 'src/domain/run.dart'; export 'src/domain/run_state.dart'; export 'src/domain/trigger.dart'; +export 'src/infrastructure/journal/hive_journal.dart'; diff --git a/runtime/core/lib/src/application/agent/agent_config.dart b/runtime/core/lib/src/application/agent/agent_config.dart new file mode 100644 index 0000000..c26e8ad --- /dev/null +++ b/runtime/core/lib/src/application/agent/agent_config.dart @@ -0,0 +1,16 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:alteri_one_contracts/alteri_one_contracts.dart'; + +/// Per-profile tool configuration seen by the agent loop +/// (docs/spec/domain.md, section 9). +/// +/// The loop pairs the result with the declared risk through +/// [effectiveRisk]: configuration may raise a tool's risk, never lower it. +abstract interface class AgentConfig { + /// The configured risk of the tool with the canonical [toolName] + /// ([ToolSpec.name], not [ToolSpec.llmName]), or `null` when the profile + /// does not override the declared risk. + Risk? riskFor(String toolName); +} diff --git a/runtime/core/lib/src/application/agent/agent_defaults.dart b/runtime/core/lib/src/application/agent/agent_defaults.dart new file mode 100644 index 0000000..58775bc --- /dev/null +++ b/runtime/core/lib/src/application/agent/agent_defaults.dart @@ -0,0 +1,67 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:alteri_one_contracts/alteri_one_contracts.dart'; + +import '../../domain/messages.dart'; +import '../../domain/run.dart'; +import 'agent_config.dart'; +import 'approval_service.dart'; +import 'context_builder.dart'; +import 'tool_set.dart'; + +/// Fail-closed placeholders for the agent-loop collaborators +/// (docs/spec/domain.md, section 9). +/// +/// They keep the composition root resolvable before the matching features +/// land: profile configuration (section 14), the interactive approval channel +/// (M4/M9), prompt rendering (section 13), and extensions (M5). Each one +/// refuses or narrows rather than guessing, in line with the fail-closed +/// defaults of the rest of the core. + +/// An [AgentConfig] without overrides: every tool keeps its declared risk. +final class DefaultAgentConfig implements AgentConfig { + /// Creates the defaults. + const new(); + + @override + Risk? riskFor(String toolName) => null; +} + +/// An [ApprovalService] that refuses every request. +/// +/// There is no interactive channel until M4/M9, so an `ask` decision always +/// ends as `not_approved` (docs/spec/domain.md, section 8.8). +final class DenyAllApprovals implements ApprovalService { + /// Creates the defaults. + const new(); + + @override + Future ask(Run run, ToolSpec tool, Map input) => + Future.value(false); +} + +/// A [ContextBuilder] that starts the run from its prompt alone. +/// +/// System instructions and context fragments are rendered in section 13; until +/// then the owner prompt is the whole conversation. +final class PromptContextBuilder implements ContextBuilder { + /// Creates the defaults. + const new(); + + @override + Future> build(Run run) => + Future.value([Message.user(run.prompt)]); +} + +/// A [ToolRegistry] without tools. +/// +/// Extensions land in M5; an empty snapshot makes every tool call fail as +/// `tool_unavailable` instead of running something unconfigured. +final class EmptyToolRegistry implements ToolRegistry { + /// Creates the defaults. + const new(); + + @override + ToolSet snapshot(String profile) => ToolSet(const []); +} diff --git a/runtime/core/lib/src/application/agent/agent_deps.dart b/runtime/core/lib/src/application/agent/agent_deps.dart new file mode 100644 index 0000000..8cdee98 --- /dev/null +++ b/runtime/core/lib/src/application/agent/agent_deps.dart @@ -0,0 +1,37 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +import '../../domain/policy.dart'; +import '../../domain/ports/journal_port.dart'; +import '../../domain/ports/llm_port.dart'; +import 'agent_config.dart'; +import 'approval_service.dart'; +import 'context_builder.dart'; +import 'tool_set.dart'; + +/// The collaborators of the agent loop (docs/spec/domain.md, section 9). +/// +/// Pure carrier: the loop holds no state of its own, so every dependency is +/// passed in here. +final class AgentDeps( + /// Model provider. + final LlmPort llm, + + /// Source of the tool snapshot for each step. + final ToolRegistry registry, + + /// Journal written before every call. + final JournalPort journal, + + /// The only component that decides whether a tool runs. + final PolicyGate policy, + + /// Owner decisions for [Decision.ask]. + final ApprovalService approvals, + + /// Per-profile risk overrides. + final AgentConfig config, + + /// Starting conversation. + final ContextBuilder context, +); diff --git a/runtime/core/lib/src/application/agent/agent_loop.dart b/runtime/core/lib/src/application/agent/agent_loop.dart new file mode 100644 index 0000000..9c39ff4 --- /dev/null +++ b/runtime/core/lib/src/application/agent/agent_loop.dart @@ -0,0 +1,155 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +import '../../domain/invocation.dart'; +import '../../domain/messages.dart'; +import '../../domain/policy.dart'; +import '../../domain/run.dart'; +import 'agent_deps.dart'; +import 'run_result.dart'; +import 'tool_output.dart'; +import 'tool_set.dart'; + +/// Runs the agent loop of one run (docs/spec/domain.md, section 9). +/// +/// The loop works only through the collaborators of [AgentDeps]: +/// [AgentDeps.llm] produces turns, [AgentDeps.registry] supplies the tool +/// snapshot of each step, [AgentDeps.policy] decides every call, +/// [AgentDeps.approvals] asks the owner when policy says so, +/// [AgentDeps.config] may raise a declared risk, [AgentDeps.context] builds the +/// opening messages, and [AgentDeps.journal] records policy denials and call +/// outcomes. The model's tool requests are intercepted and never executed by +/// the provider. +/// +/// A policy denial and a call that reaches execution are journalled. Calls +/// refused before they start (`malformed`, `tool_unavailable`, `not_approved`) +/// are deliberately not journalled. +/// +/// A step takes a fresh tool snapshot, asks the model for one turn, appends the +/// assistant message, and runs the requested tools concurrently. When the model +/// requests no tools the run is [RunCompleted]; when the step budget is used up +/// without such a reply it is [RunBudgetExceeded] (section 8.3). +/// +/// Cancellation, model failures, context failures, and registry failures +/// propagate to the caller; they are not loop outcomes. +Future runAgent(Run run, AgentDeps d) async { + // Copy: the loop appends turns and results, and the builder may return a + // shared or constant list. + final messages = [...await d.context.build(run)]; + + for (var step = 0; step < run.budget.maxSteps; step++) { + run.cancel.throwIfCancelled(); + + // A fresh snapshot on every step: a tool unloaded mid-run disappears from + // the next step and its later calls become `tool_unavailable`. + final tools = d.registry.snapshot(run.profile); + + final turn = await d.llm.complete( + messages: messages, + tools: tools.specs, + budget: run.budget, + ); + messages.add(turn.assistant); + // Checked before branching: a cancel requested during the model call must + // propagate rather than look like a completed run, and must arrive before + // any tool of the turn runs (see the class documentation). + run.cancel.throwIfCancelled(); + + if (!turn.hasToolCalls) { + return RunResult.completed(turn.text); + } + + final results = await Future.wait( + turn.toolCalls.map((call) => _invoke(run, tools, call, d)), + ); + // Likewise for a cancel requested while the tools ran. On the + // `maxSteps`-exhausted path, leaving the loop silently would report the + // budget outcome instead of the cancellation. + run.cancel.throwIfCancelled(); + messages.add(Message.toolResults(results)); + } + + run.cancel.throwIfCancelled(); + return RunResult.budgetExceeded(); +} + +/// Handles one tool call: policy, approval, journal, and execution. +/// +/// The order is fixed (docs/spec/domain.md, section 9). Refusals before a call +/// starts (`malformed`, `tool_unavailable`, `not_approved`) are not journalled; +/// a policy denial is. +Future _invoke( + Run run, + ToolSet tools, + ToolCall call, + AgentDeps d, +) async { + if (call.isMalformed) { + return ToolResultBlock.error( + call.id, + 'malformed_arguments: ${call.reason}', + ); + } + + final tool = tools.byLlmName(call.name); + if (tool == null) { + return ToolResultBlock.error(call.id, 'tool_unavailable'); + } + + final spec = tool.spec; + // Configuration may raise the declared risk, never lower it. The override is + // looked up by the canonical name; policy and journal use the LLM name. + final risk = effectiveRisk(spec.risk, d.config.riskFor(spec.name)); + + switch (d.policy.decide( + tool: call.name, + risk: risk, + taints: run.taints, + caps: run.capabilities, + )) { + case Decision.deny: + await d.journal.denied(run.id, call.name, risk, 'policy'); + return ToolResultBlock.error(call.id, 'denied'); + case Decision.ask: + if (!await d.approvals.ask(run, spec, call.input)) { + return ToolResultBlock.error(call.id, 'not_approved'); + } + break; + case Decision.allow: + break; + } + + // Written (and flushed) before the side effect; if this throws, the tool + // must not run, so the error propagates. + final seq = await d.journal.started(run.id, call.name, risk); + + // Each invocation gets its own token, so a timeout can abandon this call + // without cancelling the run. The tool receives that token, not `run.cancel`. + final token = run.cancel.child(); + final Object? out; + try { + out = await tool + .call(call.input, token) + .timeout(Duration(milliseconds: spec.timeoutMs)); + } catch (e) { + // `Future.timeout` does not cancel the tool, so request cancellation + // explicitly. The status stays `failed`: `unknownOutcome` would be a spec + // decision (docs/spec/open-questions.md, Q14). + token.cancel(); + // The full error text stays in the journal so internal details do not + // leak into the model context. + await d.journal.finished(run.id, seq, InvocationStatus.failed, '$e'); + return ToolResultBlock.error(call.id, 'failed'); + } + + // Journal, taint, and truncation happen outside the try: a failure in any + // of them must not overwrite the record of a successful call. + await d.journal.finished(run.id, seq, InvocationStatus.succeeded, null); + if (spec.untrustedOutput) { + run.addTaint(UntrustedSource(call.name)); + } + return ToolResultBlock.ok( + call.id, + truncateToolOutput(out, spec.maxOutputBytes), + ); +} diff --git a/runtime/core/lib/src/application/agent/approval_service.dart b/runtime/core/lib/src/application/agent/approval_service.dart new file mode 100644 index 0000000..a71c9f7 --- /dev/null +++ b/runtime/core/lib/src/application/agent/approval_service.dart @@ -0,0 +1,19 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:alteri_one_contracts/alteri_one_contracts.dart'; + +import '../../domain/run.dart'; + +/// Asks the owner whether a tool call may run +/// (docs/spec/domain.md, sections 8.8 and 9). +/// +/// The loop calls this only after the policy gate returned `ask`; the service +/// never decides policy itself. A refusal (`false`) becomes the +/// `not_approved` error result, and the run continues. +abstract interface class ApprovalService { + /// Shows [tool] and [input] to the owner and waits for a decision. + /// + /// Implementations apply their own timeout; no answer means refusal. + Future ask(Run run, ToolSpec tool, Map input); +} diff --git a/runtime/core/lib/src/application/agent/context_builder.dart b/runtime/core/lib/src/application/agent/context_builder.dart new file mode 100644 index 0000000..cb86559 --- /dev/null +++ b/runtime/core/lib/src/application/agent/context_builder.dart @@ -0,0 +1,16 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +import '../../domain/messages.dart'; +import '../../domain/run.dart'; + +/// Builds the starting conversation of a run +/// (docs/spec/domain.md, sections 8.8 and 9). +abstract interface class ContextBuilder { + /// Returns the messages the loop starts from: system instructions plus the + /// prompt and the context fragments of [run]. + /// + /// The loop copies the returned list before appending turns and tool + /// results, so an implementation may return a shared or constant list. + Future> build(Run run); +} diff --git a/runtime/core/lib/src/application/agent/run_result.dart b/runtime/core/lib/src/application/agent/run_result.dart new file mode 100644 index 0000000..61d5b7b --- /dev/null +++ b/runtime/core/lib/src/application/agent/run_result.dart @@ -0,0 +1,37 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +/// The outcome of the agent loop (docs/spec/domain.md, section 9). +library; + +/// What the agent loop returns when it stops. +/// +/// There are exactly two loop outcomes (docs/spec/domain.md, section 9): the +/// model produced a reply without tool calls, or the step budget ran out. +/// Use the dot shorthands at call sites: `.completed(text)` and +/// `.budgetExceeded()`. +sealed class RunResult { + /// Base constructor of the closed result hierarchy. + const new(); + + /// The model answered and requested no tools; see [RunCompleted.text]. + factory completed(String text) = RunCompleted; + + /// The loop used every step in `run.budget.maxSteps` without a final answer. + factory budgetExceeded() = RunBudgetExceeded; +} + +/// The run finished with a model reply. +final class RunCompleted extends RunResult { + /// Creates the result for [text]. + const new(this.text); + + /// The reply text of the last model turn; may be empty. + final String text; +} + +/// The run used its whole step budget without producing a final answer. +final class RunBudgetExceeded extends RunResult { + /// Creates the result. + const new(); +} diff --git a/runtime/core/lib/src/application/agent/tool_output.dart b/runtime/core/lib/src/application/agent/tool_output.dart new file mode 100644 index 0000000..0423b77 --- /dev/null +++ b/runtime/core/lib/src/application/agent/tool_output.dart @@ -0,0 +1,83 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:convert'; + +/// Truncates a tool output to at most [maxBytes] UTF-8 bytes +/// (docs/spec/domain.md, section 9). +/// +/// Internal to the core: not exported from `package:alteri_one_core`. The +/// wrapping of an untrusted output in a boundary block is a separate concern +/// (section 13) and is not done here. +/// +/// A [String] is measured as its UTF-8 encoding; any other value as +/// `jsonEncode(output)`, which requires JSON-compatible output, as the tool +/// port mandates. When the measured value fits, the original value is +/// returned unchanged, so its type is preserved. Otherwise the result is the +/// encoded text cut to at most [maxBytes] bytes on a rune boundary: a +/// multi-byte character is never split. `null` stays `null`, and a +/// non-positive limit yields an empty string. +/// +/// The function never throws. When [output] is not JSON-serializable (a cyclic +/// structure, a `DateTime`, an arbitrary object, ...), the model sees the +/// bounded fallback [unserializableOutput] instead of the value. +Object? truncateToolOutput(Object? output, int maxBytes) { + if (output == null) { + return null; + } + final String text; + final bool isFallback; + if (output is String) { + text = output; + isFallback = false; + } else { + text = _encode(output); + isFallback = text == unserializableOutput; + } + final bytes = utf8.encode(text); + if (bytes.length <= maxBytes) { + return isFallback ? text : output; + } + if (maxBytes <= 0) { + return ''; + } + + var end = maxBytes; + var lead = end - 1; + // In valid UTF-8 the first byte of the text is never a continuation byte, + // so [lead] cannot walk below zero. + while ((bytes[lead] & 0xC0) == 0x80) { + lead--; + } + if (lead + _sequenceLength(bytes[lead]) > end) { + end = lead; + } + return utf8.decode(bytes.sublist(0, end)); +} + +/// The text the model sees when a tool output cannot be JSON-encoded. +const String unserializableOutput = '[unserializable output]'; + +/// Encodes [output] as JSON, or returns [unserializableOutput] when it cannot +/// be encoded. +String _encode(Object? output) { + try { + return jsonEncode(output); + } catch (_) { + return unserializableOutput; + } +} + +/// The length in bytes of the UTF-8 sequence starting with [lead]. +int _sequenceLength(int lead) { + if (lead < 0x80) { + return 1; + } + if (lead >= 0xF0) { + return 4; + } + if (lead >= 0xE0) { + return 3; + } + return 2; +} diff --git a/runtime/core/lib/src/application/agent/tool_set.dart b/runtime/core/lib/src/application/agent/tool_set.dart new file mode 100644 index 0000000..8a096de --- /dev/null +++ b/runtime/core/lib/src/application/agent/tool_set.dart @@ -0,0 +1,42 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:alteri_one_contracts/alteri_one_contracts.dart'; + +import '../../domain/ports/tool_port.dart'; + +/// The tools available to one agent-loop step +/// (docs/spec/domain.md, section 9). +final class ToolSet(List tools) { + /// A copy of the tools, in the order they were given. + /// + /// Copied so that a registry cannot change a snapshot after handing it out. + final List _tools = List.unmodifiable(tools); + + /// The specifications of the tools, in the order the tools were given. + List get specs => [for (final tool in _tools) tool.spec]; + + /// Finds the tool the model named [llmName]. + /// + /// Matching is on [ToolSpec.llmName], the name the model sees, not on the + /// canonical [ToolSpec.name]. When several tools share an LLM name the first + /// one in the list wins; a registry never produces such duplicates, and the + /// lookup stays deterministic if one slips in. + ToolPort? byLlmName(String llmName) { + for (final tool in _tools) { + if (tool.spec.llmName == llmName) { + return tool; + } + } + return null; + } +} + +/// Provides the tools of a profile (docs/spec/domain.md, section 9). +/// +/// The agent loop takes a fresh snapshot on every step, so a tool unloaded +/// mid-run disappears from the next step (`tool_unavailable`). +abstract interface class ToolRegistry { + /// Returns the tools currently loaded for [profile]. + ToolSet snapshot(String profile); +} diff --git a/runtime/core/lib/src/di/core_module.dart b/runtime/core/lib/src/di/core_module.dart index 5f02cb8..3b95882 100644 --- a/runtime/core/lib/src/di/core_module.dart +++ b/runtime/core/lib/src/di/core_module.dart @@ -3,7 +3,15 @@ import 'package:injectable/injectable.dart'; +import '../application/agent/agent_config.dart'; +import '../application/agent/agent_defaults.dart'; +import '../application/agent/agent_deps.dart'; +import '../application/agent/approval_service.dart'; +import '../application/agent/context_builder.dart'; +import '../application/agent/tool_set.dart'; import '../domain/policy.dart'; +import '../domain/ports/journal_port.dart'; +import '../domain/ports/llm_port.dart'; /// Third-party registrations for the core container /// (docs/spec/domain.md, section 8.10). @@ -16,4 +24,35 @@ abstract class CoreModule { /// denied. @lazySingleton PolicyGate get policyGate => PolicyGate(const {}, false); + + /// Per-profile risk overrides; none are configured yet (section 14). + @lazySingleton + AgentConfig get agentConfig => const DefaultAgentConfig(); + + /// Owner decisions for `ask`; there is no channel to ask through yet. + @lazySingleton + ApprovalService get approvalService => const DenyAllApprovals(); + + /// Starting conversation; prompt wrapping lands with section 13. + @lazySingleton + ContextBuilder get contextBuilder => const PromptContextBuilder(); + + /// Loaded tools; extensions land in M5. + @lazySingleton + ToolRegistry get toolRegistry => const EmptyToolRegistry(); + + /// The collaborators of the agent loop (docs/spec/domain.md, section 9). + /// + /// Lazy on purpose: resolving this without a registered `LlmPort` (providers + /// arrive in M3) fails at resolution time, not at startup. + @lazySingleton + AgentDeps agentDeps( + LlmPort llm, + ToolRegistry registry, + JournalPort journal, + PolicyGate policy, + ApprovalService approvals, + AgentConfig config, + ContextBuilder context, + ) => AgentDeps(llm, registry, journal, policy, approvals, config, context); } diff --git a/runtime/core/lib/src/di/injection.config.dart b/runtime/core/lib/src/di/injection.config.dart index 67cdc6f..3a9901e 100644 --- a/runtime/core/lib/src/di/injection.config.dart +++ b/runtime/core/lib/src/di/injection.config.dart @@ -10,6 +10,14 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:alteri_one_core/src/application/agent/agent_config.dart' + as _i411; +import 'package:alteri_one_core/src/application/agent/agent_deps.dart' as _i982; +import 'package:alteri_one_core/src/application/agent/approval_service.dart' + as _i194; +import 'package:alteri_one_core/src/application/agent/context_builder.dart' + as _i186; +import 'package:alteri_one_core/src/application/agent/tool_set.dart' as _i454; import 'package:alteri_one_core/src/application/approvals_bloc.dart' as _i511; import 'package:alteri_one_core/src/application/channels_cubit.dart' as _i456; import 'package:alteri_one_core/src/application/extensions_bloc.dart' as _i603; @@ -18,6 +26,8 @@ import 'package:alteri_one_core/src/application/state_publisher.dart' as _i869; import 'package:alteri_one_core/src/application/triggers_cubit.dart' as _i476; import 'package:alteri_one_core/src/di/core_module.dart' as _i398; import 'package:alteri_one_core/src/domain/policy.dart' as _i665; +import 'package:alteri_one_core/src/domain/ports/journal_port.dart' as _i673; +import 'package:alteri_one_core/src/domain/ports/llm_port.dart' as _i500; import 'package:get_it/get_it.dart' as _i174; import 'package:injectable/injectable.dart' as _i526; @@ -36,6 +46,21 @@ extension GetItInjectableX on _i174.GetIt { gh.lazySingleton<_i869.StatePublisher>(() => _i869.StatePublisher()); gh.lazySingleton<_i476.TriggersCubit>(() => _i476.TriggersCubit()); gh.lazySingleton<_i665.PolicyGate>(() => coreModule.policyGate); + gh.lazySingleton<_i411.AgentConfig>(() => coreModule.agentConfig); + gh.lazySingleton<_i194.ApprovalService>(() => coreModule.approvalService); + gh.lazySingleton<_i186.ContextBuilder>(() => coreModule.contextBuilder); + gh.lazySingleton<_i454.ToolRegistry>(() => coreModule.toolRegistry); + gh.lazySingleton<_i982.AgentDeps>( + () => coreModule.agentDeps( + gh<_i500.LlmPort>(), + gh<_i454.ToolRegistry>(), + gh<_i673.JournalPort>(), + gh<_i665.PolicyGate>(), + gh<_i194.ApprovalService>(), + gh<_i411.AgentConfig>(), + gh<_i186.ContextBuilder>(), + ), + ); return this; } } diff --git a/runtime/core/lib/src/di/injection.dart b/runtime/core/lib/src/di/injection.dart index 1701a19..10fee5c 100644 --- a/runtime/core/lib/src/di/injection.dart +++ b/runtime/core/lib/src/di/injection.dart @@ -5,6 +5,8 @@ import 'package:get_it/get_it.dart'; import 'package:injectable/injectable.dart'; import '../application/bloc_defaults.dart'; +import '../domain/ports/journal_port.dart'; +import '../infrastructure/journal/hive_journal.dart'; import 'injection.config.dart'; /// The core's container. Ports (`LlmPort`, `JournalPort`, `MemoryPort`) are @@ -13,6 +15,9 @@ final getIt = GetIt.instance; /// Initializes the core container for [environment] /// (`dev`, `prod`, or `test`). +/// +/// Synchronous and free of I/O: it never reads the home directory. Components +/// that need a path (the journal) are registered separately. @InjectableInit() void configureCore({required String environment}) { // Configured exactly once, before any bloc is created @@ -20,3 +25,23 @@ void configureCore({required String environment}) { applyBlocDefaults(); getIt.init(environment: environment); } + +/// Opens a Hive-backed journal under [directory] and registers it as both the +/// concrete [HiveJournal] and the [JournalPort]. +/// +/// Throws a [StateError] when either the [HiveJournal] or the [JournalPort] is +/// already registered. Both checks happen before any box is opened, so a +/// partially registered container does not leak open boxes. +/// +/// The caller owns the journal's lifetime: the daemon entry point closes it +/// when it shuts down (M4). [configureCore] stays synchronous and does not +/// touch the home directory; choosing [directory] is the caller's job. +Future registerJournal({required String directory}) async { + if (getIt.isRegistered() || getIt.isRegistered()) { + throw StateError('A journal is already registered'); + } + final journal = await HiveJournal.open(directory); + getIt + ..registerSingleton(journal) + ..registerSingleton(journal); +} diff --git a/runtime/core/lib/src/domain/cancel_token.dart b/runtime/core/lib/src/domain/cancel_token.dart index afee97a..fe6e948 100644 --- a/runtime/core/lib/src/domain/cancel_token.dart +++ b/runtime/core/lib/src/domain/cancel_token.dart @@ -15,15 +15,31 @@ final class RunCancelledException implements Exception { final class CancelToken() { bool _cancelled = false; + /// The token this one derives from, if any. + CancelToken? _parent; + /// Whether cancellation was requested. - bool get isCancelled => _cancelled; + /// + /// A child token is cancelled when its parent is, even without its own + /// [cancel] call. + bool get isCancelled => _cancelled || (_parent?.isCancelled ?? false); /// Requests cancellation. void cancel() => _cancelled = true; + /// Returns a token that is cancelled when this one is. + /// + /// Cancelling the child affects only the child, so one tool invocation can be + /// abandoned (for example after its timeout) without cancelling the run. + CancelToken child() { + final token = CancelToken(); + token._parent = this; + return token; + } + /// Throws [RunCancelledException] if cancellation was requested. void throwIfCancelled() { - if (_cancelled) { + if (isCancelled) { throw const RunCancelledException(); } } diff --git a/runtime/core/lib/src/domain/run.dart b/runtime/core/lib/src/domain/run.dart new file mode 100644 index 0000000..b264025 --- /dev/null +++ b/runtime/core/lib/src/domain/run.dart @@ -0,0 +1,82 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +/// A run: one execution of a profile in a given mode +/// (docs/spec/domain.md, section 8.1). +library; + +import 'dart:collection'; + +import 'budget.dart'; +import 'cancel_token.dart'; +import 'ids.dart'; +import 'policy.dart'; + +/// The three run modes of the AlteriOne runtime. +enum RunMode { + /// Interactive conversation. + chat, + + /// One-shot task execution. + task, + + /// Long-lived resident run. + resident, +} + +/// A single run. +final class Run({ + /// Unique identifier of the run. + required final RunId id, + + /// Profile name driving the run. + required final String profile, + + /// Run mode. + required final RunMode mode, + + /// Initial prompt. + required final String prompt, + + /// Per-run budget. + required final Budget budget, + + /// Capabilities granted to the run. + required final RunCapabilities capabilities, + + /// Initial taint labels applied to the run. Defaults to empty (clean). + /// + /// The set is copied, so a caller cannot change the run by mutating the set + /// it passed in. Read the labels through [taints]; add one with [addTaint]. + Set taints = const {}, + + /// Cooperative cancellation token. + required final CancelToken cancel, +}) { + /// The run's taint labels. Never changed from outside. + final Set _taints = {...taints}; + + /// The live read-only view of the taint labels. + /// + /// The identity of the view is stable, but its contents are live: they grow + /// as [addTaint] is called. A caller that iterates the view across an `await` + /// can therefore hit a [ConcurrentModificationError]; callers that hold it + /// across awaits must copy it first (for example with [Set.of]). + late final UnmodifiableSetView _taintView = + UnmodifiableSetView(_taints); + + /// The taint labels applied to the run so far. + /// + /// Read-only: mutating the returned set throws [UnsupportedError], so no + /// code can clear a label. Labels are added only by [addTaint]. The view is + /// live; copy it before iterating across an `await`. + Set get taints => _taintView; + + /// Adds [source] to the run's taint labels. + /// + /// A no-op when the label is already present. Labels are never removed by + /// the run itself: only the owner clears a label, through confirmation or an + /// explicit configuration reset (docs/spec/domain.md, section 8.2; AGENTS.md + /// invariant 4). + void addTaint(UntrustedSource source) => _taints.add(source); +} diff --git a/runtime/core/lib/src/infrastructure/journal/hive_journal.dart b/runtime/core/lib/src/infrastructure/journal/hive_journal.dart new file mode 100644 index 0000000..75f3725 --- /dev/null +++ b/runtime/core/lib/src/infrastructure/journal/hive_journal.dart @@ -0,0 +1,142 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:convert'; + +import 'package:alteri_one_contracts/alteri_one_contracts.dart'; +import 'package:hive_ce/hive.dart'; + +import '../../domain/ids.dart'; +import '../../domain/invocation.dart'; +import '../../domain/ports/journal_port.dart'; + +/// Hive-backed implementation of [JournalPort]. +final class HiveJournal implements JournalPort { + new _(this._meta, this._steps); + + /// Metadata box (sequence counter). + final Box _meta; + + /// Lazy box holding journaled invocations. + final LazyBox _steps; + + /// Opens (or creates) a journal stored under [directory]. + static Future open(String directory) async { + Hive.init(directory); + final meta = await Hive.openBox('meta'); + final steps = await Hive.openLazyBox('steps'); + return HiveJournal._(meta, steps); + } + + Future close() async { + await _meta.close(); + await _steps.close(); + } + + int get _seq => int.parse(_meta.get('seq', defaultValue: '0')!); + + Future _nextSeq() async { + final next = _seq + 1; + await _meta.put('seq', next.toString()); + // The step record is append-only, so the counter must reach the disk + // before the record is written. A crash in between may skip a `seq`, which + // is harmless; reusing one would overwrite an existing record + // (docs/spec/storage.md, 15.1 and 15.2). + await _meta.flush(); + return next; + } + + String _key(RunId runId, int seq) => + '${runId.value}:${seq.toString().padLeft(12, '0')}'; + + @override + Future started(RunId runId, String tool, Risk risk) async { + final seq = await _nextSeq(); + final now = DateTime.now(); + final record = { + 'v': 1, + 'seq': seq, + 'runId': runId.value, + 'tool': tool, + 'risk': risk.name, + 'status': InvocationStatus.started.name, + 'at': now.toIso8601String(), + 'error': null, + }; + await _steps.put(_key(runId, seq), jsonEncode(record)); + await _steps.flush(); + return seq; + } + + @override + Future denied( + RunId runId, + String tool, + Risk risk, + String reason, + ) async { + final seq = await _nextSeq(); + final now = DateTime.now(); + final record = { + 'v': 1, + 'seq': seq, + 'runId': runId.value, + 'tool': tool, + 'risk': risk.name, + 'status': InvocationStatus.denied.name, + 'at': now.toIso8601String(), + 'error': reason, + }; + await _steps.put(_key(runId, seq), jsonEncode(record)); + await _steps.flush(); + } + + @override + Future finished( + RunId runId, + int seq, + InvocationStatus status, + String? error, + ) async { + final key = _key(runId, seq); + final existingRaw = await _steps.get(key); + if (existingRaw == null) { + throw StateError('No journal entry for $key'); + } + final existing = jsonDecode(existingRaw) as Map; + existing['status'] = status.name; + existing['error'] = error; + await _steps.put(key, jsonEncode(existing)); + await _steps.flush(); + } + + @override + Future> forRun(RunId runId) async { + final prefix = '${runId.value}:'; + final result = []; + for (final key in _steps.keys) { + final keyStr = key as String; + if (!keyStr.startsWith(prefix)) { + continue; + } + final raw = await _steps.get(keyStr); + if (raw == null) { + continue; + } + final json = jsonDecode(raw) as Map; + result.add( + Invocation( + json['seq'] as int, + RunId(json['runId'] as String), + json['tool'] as String, + Risk.values.byName(json['risk'] as String), + InvocationStatus.values.byName(json['status'] as String), + DateTime.parse(json['at'] as String), + json['error'] as String?, + ), + ); + } + result.sort((a, b) => a.seq.compareTo(b.seq)); + return result; + } +} diff --git a/runtime/core/test/application/agent_fakes.dart b/runtime/core/test/application/agent_fakes.dart new file mode 100644 index 0000000..fad9ca4 --- /dev/null +++ b/runtime/core/test/application/agent_fakes.dart @@ -0,0 +1,295 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +/// Test doubles for the agent loop (docs/spec/domain.md, section 9). +library; + +import 'dart:async'; + +import 'package:alteri_one_contracts/alteri_one_contracts.dart'; +import 'package:alteri_one_core/alteri_one_core.dart'; + +/// One ordered log of events written by several doubles. +/// +/// Tests assert that a journal write precedes the side effect it guards. +final class EventLog { + /// The events in the order they happened. + final List entries = []; + + /// Appends [entry]. + void add(String entry) => entries.add(entry); +} + +/// A [JournalPort] that records every write in [records] and, when [log] is +/// set, in the shared ordered [EventLog]. +final class FakeJournal implements JournalPort { + /// Creates the journal. + new([this.log]); + + /// Ordered log shared with the other doubles, if any. + final EventLog? log; + + /// One line per write, in call order. + final List records = []; + + /// Number of [started] calls. + int starts = 0; + + /// When set, [started] throws instead of recording, so a test can prove the + /// tool never runs. + bool failOnStarted = false; + + /// The risk passed to each [denied] call, in order. + final List deniedRisks = []; + + int _nextSeq = 1; + + @override + Future started(RunId runId, String tool, Risk risk) { + if (failOnStarted) { + return Future.error(StateError('journal unavailable')); + } + starts++; + records.add('started $tool'); + log?.add('journal.started $tool'); + return Future.value(_nextSeq++); + } + + @override + Future denied(RunId runId, String tool, Risk risk, String reason) { + deniedRisks.add(risk); + records.add('denied $tool $reason'); + log?.add('journal.denied $tool'); + return Future.value(); + } + + @override + Future finished( + RunId runId, + int seq, + InvocationStatus status, + String? error, + ) { + records.add( + 'finished ${status.name} $seq${error == null ? '' : ' $error'}', + ); + log?.add('journal.finished ${status.name}'); + return Future.value(); + } + + @override + Future> forRun(RunId runId) => Future.value(const []); +} + +/// A [LlmPort] driven by [onComplete] and recording every request. +final class FakeLlm implements LlmPort { + /// Creates the model fake. [onComplete] receives the zero-based call index + /// and a copy of the messages sent. + new(this.onComplete, [this.log]); + + /// Produces the turn of one call. + final Turn Function(int call, List messages) onComplete; + + /// Ordered log shared with the other doubles, if any. + final EventLog? log; + + /// The messages of each request, copied at call time. + final List> requests = []; + + /// The tool specs of each request, copied at call time. + final List> toolSets = []; + + @override + Future complete({ + required List messages, + required List tools, + required Budget budget, + }) { + final call = requests.length; + requests.add(List.of(messages)); + toolSets.add(List.of(tools)); + log?.add('llm.complete $call'); + return Future.value(onComplete(call, List.of(messages))); + } +} + +/// A [ToolPort] that records calls and returns [output], throws [error], or +/// never completes. +final class FakeTool implements ToolPort { + /// Creates the tool. + new( + this.spec, { + this.output, + this.error, + this.never = false, + this.delay, + this.onCall, + this.log, + }); + + @override + final ToolSpec spec; + + /// Ordered log shared with the other doubles, if any. + final EventLog? log; + + /// The value returned on success. + final Object? output; + + /// The error thrown instead of returning [output]. + final Object? error; + + /// When set, [call] returns a future that never completes. + final bool never; + + /// When set, [call] completes after this delay, so a test can time it out. + final Duration? delay; + + /// Runs at the start of [call], for tests that cancel the run from inside a + /// tool. + final void Function()? onCall; + + /// Number of calls. + int calls = 0; + + /// The inputs of the calls, in order. + final List> inputs = []; + + /// The cancellation token of each call, in order. + final List cancels = []; + + @override + Future call(Map input, CancelToken cancel) { + calls++; + inputs.add(input); + cancels.add(cancel); + log?.add('tool.call ${spec.llmName}'); + onCall?.call(); + if (error != null) { + return Future.error(error!); + } + if (never) { + return Completer().future; + } + if (delay != null) { + return Future.delayed(delay!, () => output); + } + return Future.value(output); + } +} + +/// A [ToolRegistry] driven by [toolsOf], which receives the zero-based step. +final class FakeRegistry implements ToolRegistry { + /// Creates the registry from a per-step function. + new(this.toolsOf); + + /// The snapshot of one step. + final ToolSet Function(int step) toolsOf; + + /// Number of snapshots taken. + int calls = 0; + + /// The profiles asked for, in order. + final List profiles = []; + + @override + ToolSet snapshot(String profile) { + profiles.add(profile); + return toolsOf(calls++); + } +} + +/// A [ContextBuilder] that counts calls and returns a fixed list. +final class FakeContext implements ContextBuilder { + /// Creates the builder. + new([this.messages = const []]); + + /// The messages to start from. + final List messages; + + /// Number of [build] calls. + int builds = 0; + + @override + Future> build(Run run) { + builds++; + return Future.value(messages); + } +} + +/// An [AgentConfig] that records the names it is asked about. +final class FakeConfig implements AgentConfig { + /// Creates the config with optional [risks] by canonical tool name. + new({this.risks = const {}}); + + /// Configured risks by canonical [ToolSpec.name]. + final Map risks; + + /// The names [riskFor] received, in order. + final List asked = []; + + @override + Risk? riskFor(String toolName) { + asked.add(toolName); + return risks[toolName]; + } +} + +/// An [ApprovalService] with a fixed answer. +final class FakeApprovals implements ApprovalService { + /// Creates the service that answers every request with [answer]. + new(this.answer); + + /// The decision returned for every request. + final bool answer; + + /// Number of [ask] calls. + int asks = 0; + + @override + Future ask(Run run, ToolSpec tool, Map input) { + asks++; + return Future.value(answer); + } +} + +/// Builds a [ToolSpec] with test defaults. +/// +/// `untrustedOutput` defaults to `true`, mirroring the production +/// [ToolSpec.defaults] default (docs/spec/domain.md, section 8.2). +ToolSpec fakeSpec( + String name, { + String? llmName, + Risk risk = Risk.read, + int timeoutMs = 1_000, + int maxOutputBytes = 1_024, + bool untrustedOutput = true, +}) => ToolSpec( + name, + llmName ?? 'ext__$name', + 'test tool $name', + risk, + timeoutMs, + maxOutputBytes, + untrustedOutput, + null, +); + +/// Builds a [Run] with test defaults. +Run fakeRun({ + String profile = 'test', + int maxSteps = 4, + Set tools = const {}, + CancelToken? cancel, + Set taints = const {}, + RunMode mode = RunMode.task, +}) => Run( + id: RunId('run-1'), + profile: profile, + mode: mode, + prompt: 'hello', + budget: Budget(maxSteps, 60_000, const Duration(seconds: 30), 2_048), + capabilities: RunCapabilities(tools, const {}, const [], MemoryRights.none), + taints: taints, + cancel: cancel ?? CancelToken(), +); diff --git a/runtime/core/test/application/agent_loop_test.dart b/runtime/core/test/application/agent_loop_test.dart new file mode 100644 index 0000000..b5ee91f --- /dev/null +++ b/runtime/core/test/application/agent_loop_test.dart @@ -0,0 +1,817 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:alteri_one_contracts/alteri_one_contracts.dart'; +import 'package:alteri_one_core/alteri_one_core.dart'; +import 'package:test/test.dart'; + +import 'agent_fakes.dart'; + +/// A turn in which the model only requests [calls]. +Turn _toolTurn(List calls) => + Turn('', Message.assistant('', calls), calls); + +/// A turn with a final reply and no tool calls. +Turn _finalTurn(String text) => Turn(text, Message.assistant(text), const []); + +/// A well-formed call of the tool [name]. +ToolCall _call(String id, String name) => + ToolCall.valid(id: id, name: name, input: const {'q': 'x'}); + +AgentDeps _deps({ + required FakeLlm llm, + FakeJournal? journal, + FakeRegistry? registry, + PolicyGate? policy, + FakeApprovals? approvals, + FakeConfig? config, + FakeContext? context, +}) => AgentDeps( + llm, + registry ?? FakeRegistry((_) => ToolSet(const [])), + journal ?? FakeJournal(), + policy ?? PolicyGate(const {}, false), + approvals ?? FakeApprovals(false), + config ?? FakeConfig(), + context ?? FakeContext(), +); + +void main() { + group('runAgent', () { + test( + 'returns the reply and stops when the model requests no tools', + () async { + final context = FakeContext([Message.user('hello')]); + final llm = FakeLlm((call, messages) => _finalTurn('hi there')); + final journal = FakeJournal(); + + final result = await runAgent( + fakeRun(), + _deps(llm: llm, journal: journal, context: context), + ); + + expect( + result, + isA().having((r) => r.text, 'text', 'hi there'), + ); + expect(context.builds, 1, reason: 'context is built once'); + expect(llm.requests, hasLength(1)); + expect(llm.requests.single.map((m) => m.text), ['hello']); + expect(journal.records, isEmpty); + }, + ); + + test('journals before the tool call and feeds the result back', () async { + final events = EventLog(); + final journal = FakeJournal(events); + final tool = FakeTool(fakeSpec('echo'), output: 'done', log: events); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([_call('c1', 'ext__echo')]); + } + return _finalTurn('finished'); + }, events); + + final result = await runAgent( + fakeRun(tools: {'ext__echo'}), + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + ), + ); + + expect(result, isA()); + expect(events.entries, [ + 'llm.complete 0', + 'journal.started ext__echo', + 'tool.call ext__echo', + 'journal.finished succeeded', + 'llm.complete 1', + ]); + expect(journal.records, ['started ext__echo', 'finished succeeded 1']); + expect(tool.calls, 1); + + final toolMessage = llm.requests[1].last; + expect(toolMessage.role, MessageRole.tool); + expect(toolMessage.toolResults.single.callId, 'c1'); + expect(toolMessage.toolResults.single.value, 'done'); + expect(toolMessage.toolResults.single.isOk, isTrue); + }); + + test('taints the run only for tools with untrusted output', () async { + final read = FakeTool(fakeSpec('read'), output: 'data'); + final compute = FakeTool( + fakeSpec('compute', untrustedOutput: false), + output: 42, + ); + final post = FakeTool( + fakeSpec('post', risk: Risk.external), + output: 'ok', + ); + final journal = FakeJournal(); + final run = fakeRun( + maxSteps: 3, + tools: {'ext__read', 'ext__compute', 'ext__post'}, + ); + final llm = FakeLlm( + (call, messages) => switch (call) { + 0 => _toolTurn([ + _call('c1', 'ext__read'), + _call('c2', 'ext__compute'), + ]), + 1 => _toolTurn([_call('c3', 'ext__post')]), + _ => _finalTurn('finished'), + }, + ); + + final result = await runAgent( + run, + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([read, compute, post])), + policy: PolicyGate(const { + Risk.read: Decision.allow, + Risk.external: Decision.allow, + }, false), + ), + ); + + expect(result, isA()); + expect(run.taints.map((t) => t.value), ['ext__read']); + expect(read.calls, 1); + expect(compute.calls, 1, reason: 'untrustedOutput: false adds no label'); + expect(post.calls, 0, reason: 'a tainted run cannot act externally'); + expect(journal.records, contains('denied ext__post policy')); + expect(llm.requests[2].last.toolResults.single.errorCode, 'denied'); + }); + + test('a configured risk raise reaches policy and the journal', () async { + final tool = FakeTool(fakeSpec('read'), output: 'data'); + final journal = FakeJournal(); + final config = FakeConfig(risks: {'read': Risk.destructive}); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([_call('c1', 'ext__read')]); + } + return _finalTurn('finished'); + }); + + final result = await runAgent( + fakeRun(tools: {'ext__read'}), + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + // A read would be allowed, so only a raised risk can deny the call. + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + config: config, + ), + ); + + expect(result, isA()); + expect(config.asked, ['read']); + expect(tool.calls, 0); + expect(journal.records, ['denied ext__read policy']); + expect(journal.deniedRisks, [Risk.destructive]); + expect(llm.requests[1].last.toolResults.single.errorCode, 'denied'); + }); + + test('a configured risk cannot lower a declared risk', () async { + final tool = FakeTool( + fakeSpec('wipe', risk: Risk.destructive), + output: 'gone', + ); + final journal = FakeJournal(); + final config = FakeConfig(risks: {'wipe': Risk.read}); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([_call('c1', 'ext__wipe')]); + } + return _finalTurn('finished'); + }); + + final result = await runAgent( + fakeRun(tools: {'ext__wipe'}), + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + // The configured read would be allowed; the declared destructive is + // denied, proving the effective risk is raised to the declared one. + policy: PolicyGate(const { + Risk.read: Decision.allow, + Risk.destructive: Decision.deny, + }, false), + config: config, + ), + ); + + expect(result, isA()); + expect(tool.calls, 0); + expect(journal.deniedRisks, [Risk.destructive]); + expect(llm.requests[1].last.toolResults.single.errorCode, 'denied'); + }); + + test('both untrusted tools in one step taint the run', () async { + final first = FakeTool(fakeSpec('read'), output: 'a'); + final second = FakeTool(fakeSpec('fetch'), output: 'b'); + final journal = FakeJournal(); + final run = fakeRun(tools: {'ext__read', 'ext__fetch'}); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([ + _call('c1', 'ext__read'), + _call('c2', 'ext__fetch'), + ]); + } + return _finalTurn('finished'); + }); + + final result = await runAgent( + run, + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([first, second])), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + ), + ); + + expect(result, isA()); + expect(first.calls, 1); + expect(second.calls, 1); + expect(run.taints.map((t) => t.value).toSet(), { + 'ext__read', + 'ext__fetch', + }); + }); + + test('a failed untrusted tool does not taint the run', () async { + final tool = FakeTool( + fakeSpec('read'), + error: StateError('upstream down'), + ); + final journal = FakeJournal(); + final run = fakeRun(tools: {'ext__read'}); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([_call('c1', 'ext__read')]); + } + return _finalTurn('finished'); + }); + + final result = await runAgent( + run, + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + ), + ); + + expect(result, isA()); + expect(tool.calls, 1); + expect(run.taints, isEmpty); + expect(journal.records.last, startsWith('finished failed 1')); + expect(llm.requests[1].last.toolResults.single.errorCode, 'failed'); + }); + + test('denies a tool missing from the run capabilities', () async { + final tool = FakeTool(fakeSpec('echo'), output: 'done'); + final journal = FakeJournal(); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([_call('c1', 'ext__echo')]); + } + return _finalTurn('finished'); + }); + + final result = await runAgent( + fakeRun(), + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + ), + ); + + expect(result, isA()); + expect(journal.records, ['denied ext__echo policy']); + expect(journal.starts, 0); + expect(tool.calls, 0); + expect(llm.requests[1].last.toolResults.single.errorCode, 'denied'); + }); + + test('runs an asked tool when the owner approves', () async { + final tool = FakeTool(fakeSpec('save', risk: Risk.write), output: 'ok'); + final journal = FakeJournal(); + final approvals = FakeApprovals(true); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([_call('c1', 'ext__save')]); + } + return _finalTurn('finished'); + }); + + final result = await runAgent( + fakeRun(tools: {'ext__save'}), + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.write: Decision.ask}, true), + approvals: approvals, + ), + ); + + expect(result, isA()); + expect(approvals.asks, 1); + expect(tool.calls, 1); + expect(journal.records, ['started ext__save', 'finished succeeded 1']); + }); + + test('returns not_approved when the owner refuses', () async { + final tool = FakeTool(fakeSpec('save', risk: Risk.write), output: 'ok'); + final journal = FakeJournal(); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([_call('c1', 'ext__save')]); + } + return _finalTurn('finished'); + }); + + final result = await runAgent( + fakeRun(tools: {'ext__save'}), + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.write: Decision.ask}, true), + approvals: FakeApprovals(false), + ), + ); + + expect(result, isA()); + expect(tool.calls, 0); + expect(journal.records, isEmpty); + expect(llm.requests[1].last.toolResults.single.errorCode, 'not_approved'); + }); + + test('answers a malformed call without running the tool', () async { + final tool = FakeTool(fakeSpec('echo'), output: 'done'); + final journal = FakeJournal(); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([ + ToolCall.malformed( + id: 'c1', + name: 'ext__echo', + reason: 'missing q', + ), + ]); + } + return _finalTurn('finished'); + }); + + final result = await runAgent( + fakeRun(tools: {'ext__echo'}), + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + ), + ); + + expect(result, isA()); + expect(tool.calls, 0); + expect(journal.records, isEmpty); + expect( + llm.requests[1].last.toolResults.single.errorCode, + 'malformed_arguments: missing q', + ); + }); + + test('answers an unknown tool with tool_unavailable', () async { + final tool = FakeTool(fakeSpec('echo'), output: 'done'); + final journal = FakeJournal(); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([_call('c1', 'ext__missing')]); + } + return _finalTurn('finished'); + }); + + final result = await runAgent( + fakeRun(tools: {'ext__missing'}), + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + ), + ); + + expect(result, isA()); + expect(tool.calls, 0); + expect(journal.records, isEmpty); + expect( + llm.requests[1].last.toolResults.single.errorCode, + 'tool_unavailable', + ); + }); + + test('takes a fresh tool snapshot on every step', () async { + final tool = FakeTool(fakeSpec('a'), output: 'done'); + final journal = FakeJournal(); + final llm = FakeLlm((call, messages) { + if (call < 2) { + return _toolTurn([_call('c$call', 'ext__a')]); + } + return _finalTurn('finished'); + }); + var step = 0; + + final result = await runAgent( + fakeRun(tools: {'ext__a'}), + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry( + (_) => step++ == 0 ? ToolSet([tool]) : ToolSet(const []), + ), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + ), + ); + + expect(result, isA()); + expect(tool.calls, 1); + expect( + llm.requests[2].last.toolResults.single.errorCode, + 'tool_unavailable', + ); + }); + + test('hides the tool error text from the model', () async { + final tool = FakeTool( + fakeSpec('boom'), + error: StateError('secret detail'), + ); + final journal = FakeJournal(); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([_call('c1', 'ext__boom')]); + } + return _finalTurn('finished'); + }); + + final result = await runAgent( + fakeRun(tools: {'ext__boom'}), + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + ), + ); + + expect(result, isA()); + expect(journal.records, [ + 'started ext__boom', + 'finished failed 1 Bad state: secret detail', + ]); + final toolResult = llm.requests[1].last.toolResults.single; + expect(toolResult.errorCode, 'failed'); + expect(toolResult.value, isNull); + final modelVisible = llm.requests[1].map((m) => m.text).join(); + expect(modelVisible, isNot(contains('secret detail'))); + }); + + test('does not run the tool when the journal write fails', () async { + final tool = FakeTool(fakeSpec('a'), output: 'done'); + final journal = FakeJournal()..failOnStarted = true; + final llm = FakeLlm( + (call, messages) => _toolTurn([_call('c$call', 'ext__a')]), + ); + + await expectLater( + runAgent( + fakeRun(tools: {'ext__a'}), + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + ), + ), + throwsA(isA()), + ); + + expect(tool.calls, 0); + expect(journal.records, isEmpty); + }); + + test('times out with a failed result', () async { + final tool = FakeTool(fakeSpec('slow', timeoutMs: 1), never: true); + final journal = FakeJournal(); + final run = fakeRun(tools: {'ext__slow'}); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([_call('c1', 'ext__slow')]); + } + return _finalTurn('finished'); + }); + + final result = await runAgent( + run, + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + ), + ); + + expect(result, isA()); + expect(tool.calls, 1); + expect(journal.records.first, 'started ext__slow'); + final finished = journal.records + .where((r) => r.startsWith('finished')) + .toList(); + expect(finished, hasLength(1)); + expect(finished.single, startsWith('finished failed 1')); + expect(finished.single, contains('TimeoutException')); + expect(llm.requests[1].last.toolResults.single.errorCode, 'failed'); + // `Future.timeout` cannot cancel the tool, so the loop cancels the + // per-invocation token instead; the run's token stays clean and the tool + // never received it. + expect(tool.cancels.single.isCancelled, isTrue); + expect(run.cancel.isCancelled, isFalse); + expect(tool.cancels.single, isNot(same(run.cancel))); + }); + + test('ignores a tool that completes after its timeout', () async { + final tool = FakeTool( + fakeSpec('slow', timeoutMs: 1), + output: 'late', + delay: const Duration(milliseconds: 20), + ); + final journal = FakeJournal(); + final run = fakeRun(tools: {'ext__slow'}); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([_call('c1', 'ext__slow')]); + } + return _finalTurn('finished'); + }); + + final result = await runAgent( + run, + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + ), + ); + + expect(result, isA()); + expect(tool.calls, 1); + expect(tool.cancels.single.isCancelled, isTrue); + expect( + journal.records.where((r) => r.startsWith('finished')), + hasLength(1), + reason: 'the late completion must not journal another outcome', + ); + expect(journal.records.last, startsWith('finished failed 1')); + expect(llm.requests[1].last.toolResults.single.errorCode, 'failed'); + expect(run.taints, isEmpty); + + // Let the abandoned future complete; it must stay unobserved, and an + // untrusted tool that finishes late must not taint the run. + await Future.delayed(const Duration(milliseconds: 40)); + expect( + journal.records.where((r) => r.startsWith('finished')), + hasLength(1), + ); + expect(run.taints, isEmpty); + expect(llm.requests, hasLength(2)); + }); + + test('journals an unserializable output once and replaces it', () async { + final tool = FakeTool(fakeSpec('odd'), output: Object()); + final journal = FakeJournal(); + final run = fakeRun(tools: {'ext__odd'}); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([_call('c1', 'ext__odd')]); + } + return _finalTurn('finished'); + }); + + final result = await runAgent( + run, + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + ), + ); + + expect(result, isA()); + expect( + journal.records.where((r) => r.startsWith('finished')), + hasLength(1), + reason: 'truncation must not rewrite a successful record', + ); + expect(journal.records.last, 'finished succeeded 1'); + expect(run.taints, hasLength(1)); + final toolResult = llm.requests[1].last.toolResults.single; + expect(toolResult.isOk, isTrue); + expect(toolResult.value, '[unserializable output]'); + }); + + test('reports budgetExceeded after the last step', () async { + final tool = FakeTool(fakeSpec('a'), output: 'done'); + final journal = FakeJournal(); + final llm = FakeLlm( + (call, messages) => _toolTurn([_call('c$call', 'ext__a')]), + ); + + final result = await runAgent( + fakeRun(maxSteps: 2, tools: {'ext__a'}), + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + ), + ); + + expect(result, isA()); + expect(llm.requests, hasLength(2)); + expect(tool.calls, 2); + expect(journal.starts, 2); + expect( + journal.records.where((r) => r.startsWith('finished succeeded')), + hasLength(2), + ); + expect(llm.requests.last.last.toolResults.single.isOk, isTrue); + }); + + test('throws when the token is cancelled before the first step', () async { + final cancel = CancelToken()..cancel(); + final context = FakeContext(); + final journal = FakeJournal(); + final llm = FakeLlm((call, messages) => _finalTurn('never')); + + await expectLater( + runAgent( + fakeRun(cancel: cancel), + _deps(llm: llm, journal: journal, context: context), + ), + throwsA(isA()), + ); + + expect(llm.requests, isEmpty); + expect(journal.records, isEmpty); + expect(context.builds, 1); + }); + + test('throws when cancelled between steps', () async { + final journal = FakeJournal(); + late final FakeTool tool; + final run = fakeRun(tools: {'ext__a'}); + tool = FakeTool(fakeSpec('a'), output: 'done', onCall: run.cancel.cancel); + final llm = FakeLlm( + (call, messages) => _toolTurn([_call('c$call', 'ext__a')]), + ); + + await expectLater( + runAgent( + run, + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + ), + ), + throwsA(isA()), + ); + + expect(tool.calls, 1); + expect(llm.requests, hasLength(1)); + }); + + test('throws when cancelled during the final model call', () async { + final journal = FakeJournal(); + late final Run run; + final llm = FakeLlm((call, messages) { + run.cancel.cancel(); + return _finalTurn('swallowed'); + }); + run = fakeRun(); + + await expectLater( + runAgent(run, _deps(llm: llm, journal: journal)), + throwsA(isA()), + ); + + expect(llm.requests, hasLength(1)); + expect(journal.records, isEmpty); + }); + + test('throws before any tool when cancelled during a model turn with ' + 'tool calls', () async { + final journal = FakeJournal(); + late final FakeTool tool; + late final Run run; + final llm = FakeLlm((call, messages) { + run.cancel.cancel(); + return _toolTurn([_call('c1', 'ext__a')]); + }); + tool = FakeTool(fakeSpec('a'), output: 'done'); + run = fakeRun(tools: {'ext__a'}); + + await expectLater( + runAgent( + run, + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + ), + ), + throwsA(isA()), + ); + + expect(llm.requests, hasLength(1)); + expect(tool.calls, 0); + expect(journal.records, isEmpty); + }); + + test('throws when cancelled during the last step tools', () async { + final journal = FakeJournal(); + late final FakeTool tool; + final run = fakeRun(maxSteps: 1, tools: {'ext__a'}); + tool = FakeTool(fakeSpec('a'), output: 'done', onCall: run.cancel.cancel); + final llm = FakeLlm( + (call, messages) => _toolTurn([_call('c$call', 'ext__a')]), + ); + + await expectLater( + runAgent( + run, + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + ), + ), + throwsA(isA()), + ); + + expect(tool.calls, 1); + expect(llm.requests, hasLength(1)); + expect(journal.records, ['started ext__a', 'finished succeeded 1']); + }); + + test('separates the canonical name from the LLM name', () async { + final tool = FakeTool(fakeSpec('forecast', llmName: 'weather__forecast')); + final journal = FakeJournal(); + final config = FakeConfig(); + final llm = FakeLlm((call, messages) { + if (call == 0) { + return _toolTurn([_call('c1', 'weather__forecast')]); + } + return _finalTurn('finished'); + }); + + final result = await runAgent( + fakeRun(tools: {'weather__forecast'}), + _deps( + llm: llm, + journal: journal, + registry: FakeRegistry((_) => ToolSet([tool])), + policy: PolicyGate(const {Risk.read: Decision.allow}, false), + config: config, + ), + ); + + expect(result, isA()); + expect(config.asked, ['forecast']); + expect(journal.records, [ + 'started weather__forecast', + 'finished succeeded 1', + ]); + expect(llm.toolSets.first.single.llmName, 'weather__forecast'); + }); + }); +} diff --git a/runtime/core/test/application/tool_output_test.dart b/runtime/core/test/application/tool_output_test.dart new file mode 100644 index 0000000..2deef57 --- /dev/null +++ b/runtime/core/test/application/tool_output_test.dart @@ -0,0 +1,92 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:alteri_one_core/src/application/agent/tool_output.dart'; +import 'package:test/test.dart'; + +void main() { + group('truncateToolOutput', () { + test('leaves a short string untouched', () { + expect(truncateToolOutput('hello', 16), 'hello'); + }); + + test('leaves a string exactly at the limit untouched', () { + expect(truncateToolOutput('12345', 5), '12345'); + }); + + test('cuts an over-limit ASCII string to exactly maxBytes', () { + expect(truncateToolOutput('0123456789', 4), '0123'); + expect( + utf8.encode(truncateToolOutput('0123456789', 4)! as String), + hasLength(4), + ); + }); + + test('never splits a multi-byte rune at the boundary', () { + // 'é' is two bytes; a three-byte prefix would end inside the second one. + expect(truncateToolOutput('ééé', 3), 'é'); + // '€' is three bytes; a two-byte prefix is only half of it. + expect(truncateToolOutput('€€', 2), ''); + // A rune that ends exactly at the limit is kept. + expect(truncateToolOutput('éa', 2), 'é'); + expect(truncateToolOutput('aé', 2), 'a'); + }); + + test('measures a non-string output as JSON and truncates the text', () { + // `{"key":"value"}` is 15 bytes. + expect( + truncateToolOutput({'key': 'value'}, 15), + isA>(), + ); + expect(truncateToolOutput({'key': 'value'}, 10), '{"key":"va'); + expect(truncateToolOutput([1, 2, 3], 4), '[1,2'); + }); + + test('returns a fitting non-string value unchanged', () { + final input = {'key': 'value'}; + expect(truncateToolOutput(input, 15), same(input)); + expect(truncateToolOutput(input, 100), same(input)); + }); + + test('keeps a four-byte rune exactly at the boundary', () { + // 😀 is U+1F600, four bytes in UTF-8. + expect(truncateToolOutput('😀', 4), '😀'); + expect(truncateToolOutput('😀a', 4), '😀'); + // A three-byte prefix is only part of the rune. + expect(truncateToolOutput('😀a', 3), ''); + }); + + test('returns a bounded fallback for an unserializable output', () { + expect(truncateToolOutput(Object(), 1_024), '[unserializable output]'); + + final cyclic = {}; + cyclic['self'] = cyclic; + expect(truncateToolOutput(cyclic, 1_024), '[unserializable output]'); + + // The fallback is bounded by the same limit. + expect(truncateToolOutput(Object(), 4), '[uns'); + expect(truncateToolOutput(Object(), 0), ''); + }); + + test('does not throw for a byte buffer', () { + // `Uint8List` is a JSON-encodable number array, so jsonEncode accepts it + // and the value fits unchanged. + final bytes = Uint8List.fromList([1, 2, 3]); + expect(truncateToolOutput(bytes, 1_024), same(bytes)); + }); + + test('a non-positive limit yields an empty string', () { + expect(truncateToolOutput('abc', 0), ''); + expect(truncateToolOutput('abc', -5), ''); + expect(truncateToolOutput({'a': 1}, 0), ''); + }); + + test('keeps null unchanged', () { + expect(truncateToolOutput(null, 0), isNull); + expect(truncateToolOutput(null, 1024), isNull); + }); + }); +} diff --git a/runtime/core/test/application/tool_set_test.dart b/runtime/core/test/application/tool_set_test.dart new file mode 100644 index 0000000..bffd35f --- /dev/null +++ b/runtime/core/test/application/tool_set_test.dart @@ -0,0 +1,83 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:alteri_one_contracts/alteri_one_contracts.dart'; +import 'package:alteri_one_core/alteri_one_core.dart'; +import 'package:test/test.dart'; + +final class _FakeTool implements ToolPort { + new(this.spec); + + @override + final ToolSpec spec; + + @override + Future call(Map input, CancelToken cancel) => + Future.value(); +} + +ToolSpec _spec(String name, String llmName) => ToolSpec.defaults( + name: name, + llmName: llmName, + description: 'test tool $name', + risk: Risk.read, +); + +void main() { + group('ToolSet.specs', () { + test('preserves the order of the tools', () { + final first = _FakeTool(_spec('alpha', 'ext__alpha')); + final second = _FakeTool(_spec('beta', 'ext__beta')); + final set = ToolSet([first, second]); + + expect(set.specs.map((s) => s.name), ['alpha', 'beta']); + expect(set.specs.map((s) => s.llmName), ['ext__alpha', 'ext__beta']); + }); + + test('copies the list given to it', () { + final first = _FakeTool(_spec('alpha', 'ext__alpha')); + final source = [first]; + final set = ToolSet(source); + + source + ..clear() + ..add(_FakeTool(_spec('beta', 'ext__beta'))); + + expect(set.specs.map((s) => s.name), ['alpha']); + expect(set.byLlmName('ext__alpha'), same(first)); + expect(set.byLlmName('ext__beta'), isNull); + }); + }); + + group('ToolSet.byLlmName', () { + test('matches the LLM name, not the canonical name', () { + final tool = _FakeTool(_spec('forecast', 'weather__forecast')); + final set = ToolSet([tool]); + + expect(set.byLlmName('weather__forecast'), same(tool)); + expect(set.byLlmName('forecast'), isNull); + }); + + test('returns null for an unknown name', () { + final set = ToolSet([_FakeTool(_spec('alpha', 'ext__alpha'))]); + + expect(set.byLlmName('ext__other'), isNull); + expect(set.byLlmName(''), isNull); + }); + + test('an empty set has no tools', () { + final set = ToolSet(const []); + + expect(set.specs, isEmpty); + expect(set.byLlmName('ext__alpha'), isNull); + }); + + test('the first of duplicate LLM names wins', () { + final first = _FakeTool(_spec('first', 'ext__dup')); + final second = _FakeTool(_spec('second', 'ext__dup')); + final set = ToolSet([first, second]); + + expect(set.byLlmName('ext__dup'), same(first)); + }); + }); +} diff --git a/runtime/core/test/di/injection_test.dart b/runtime/core/test/di/injection_test.dart index 895872c..d30fd07 100644 --- a/runtime/core/test/di/injection_test.dart +++ b/runtime/core/test/di/injection_test.dart @@ -1,12 +1,30 @@ // Copyright 2026 The AlteriOne Authors. // SPDX-License-Identifier: Apache-2.0 +import 'dart:io'; + import 'package:alteri_one_contracts/alteri_one_contracts.dart'; import 'package:alteri_one_core/alteri_one_core.dart'; import 'package:test/test.dart'; +import '../application/agent_fakes.dart'; + void main() { - tearDown(getIt.reset); + Directory? tempDir; + + tearDown(() async { + // The container owns the journal only as a value; closing is the caller's + // job (see `registerJournal`), so do it before the boxes' directory goes. + if (getIt.isRegistered()) { + await getIt().close(); + } + await getIt.reset(); + final dir = tempDir; + tempDir = null; + if (dir != null) { + await dir.delete(recursive: true); + } + }); test('configureCore registers the state components', () { configureCore(environment: 'test'); @@ -43,4 +61,133 @@ void main() { expect(identical(getIt(), getIt()), isTrue); }); + + group('fail-closed agent defaults', () { + test('AgentConfig keeps the declared risk', () { + configureCore(environment: 'test'); + + expect(getIt().riskFor('weather__forecast'), isNull); + }); + + test('ApprovalService refuses every request', () async { + configureCore(environment: 'test'); + + final approved = await getIt().ask( + fakeRun(), + fakeSpec('weather__forecast'), + const {}, + ); + expect(approved, isFalse); + }); + + test('ToolRegistry is empty', () { + configureCore(environment: 'test'); + + expect(getIt().snapshot('test').specs, isEmpty); + }); + + test('ContextBuilder starts from the prompt alone', () async { + configureCore(environment: 'test'); + final run = fakeRun(); + + final messages = await getIt().build(run); + expect(messages, hasLength(1)); + expect(messages.single.role, MessageRole.user); + expect(messages.single.text, contains(run.prompt)); + }); + }); + + group('registerJournal', () { + test('registers a HiveJournal as the JournalPort', () async { + configureCore(environment: 'test'); + tempDir = await Directory.systemTemp.createTemp('injection_journal_'); + + await registerJournal(directory: tempDir!.path); + + expect(getIt(), isA()); + expect(identical(getIt(), getIt()), isTrue); + + // A started/finished round-trip through the container. + final run = RunId('run-a'); + final seq = await getIt().started(run, 'tool', Risk.read); + await getIt().finished( + run, + seq, + InvocationStatus.succeeded, + null, + ); + final invocations = await getIt().forRun(run); + expect(invocations, hasLength(1)); + expect(invocations.single.status, InvocationStatus.succeeded); + }); + + test('throws when a JournalPort is already registered', () async { + configureCore(environment: 'test'); + tempDir = await Directory.systemTemp.createTemp('injection_journal_'); + await registerJournal(directory: tempDir!.path); + + await expectLater( + registerJournal(directory: tempDir!.path), + throwsA(isA()), + ); + }); + + test('throws when only a HiveJournal is already registered', () async { + configureCore(environment: 'test'); + tempDir = await Directory.systemTemp.createTemp('injection_journal_'); + final existing = await HiveJournal.open(tempDir!.path); + getIt.registerSingleton(existing); + + // Both registrations are checked before any box is opened, so this does + // not try to open a second set of boxes on the same directory. + await expectLater( + registerJournal(directory: tempDir!.path), + throwsA(isA()), + ); + expect(getIt.isRegistered(), isFalse); + expect(identical(getIt(), existing), isTrue); + }); + }); + + group('AgentDeps', () { + test('resolves from the registered collaborators', () { + configureCore(environment: 'test'); + final llm = FakeLlm( + (call, messages) => Turn('done', Message.assistant('done'), const []), + ); + final journal = FakeJournal(); + getIt + ..registerSingleton(llm) + ..registerSingleton(journal); + + final deps = getIt(); + expect(identical(deps.llm, llm), isTrue); + expect(identical(deps.registry, getIt()), isTrue); + expect(identical(deps.journal, journal), isTrue); + expect(identical(deps.policy, getIt()), isTrue); + expect(identical(deps.approvals, getIt()), isTrue); + expect(identical(deps.config, getIt()), isTrue); + expect(identical(deps.context, getIt()), isTrue); + }); + + test('fails only when resolved without an LlmPort', () { + configureCore(environment: 'test'); + getIt.registerSingleton(FakeJournal()); + + AgentDeps resolve() => getIt(); + + // Providers land in M3: the binding is lazy, so startup stays green and + // only resolution fails. + expect( + resolve, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('LlmPort'), + ), + ), + ); + }); + }); } diff --git a/runtime/core/test/domain/run_state_test.dart b/runtime/core/test/domain/run_state_test.dart index 0e9b154..9cbda74 100644 --- a/runtime/core/test/domain/run_state_test.dart +++ b/runtime/core/test/domain/run_state_test.dart @@ -102,5 +102,60 @@ void main() { expect(token.isCancelled, isTrue); expect(token.throwIfCancelled, throwsA(isA())); }); + + test('cancelling the parent cancels its child', () { + final parent = CancelToken(); + final child = parent.child(); + expect(child.isCancelled, isFalse); + + parent.cancel(); + + expect(child.isCancelled, isTrue); + expect(child.throwIfCancelled, throwsA(isA())); + }); + + test('a grandchild inherits cancellation from the root', () { + final root = CancelToken(); + final grandchild = root.child().child(); + + root.cancel(); + + expect(grandchild.isCancelled, isTrue); + expect( + grandchild.throwIfCancelled, + throwsA(isA()), + ); + }); + + test('cancelling a child leaves the parent clean', () { + final parent = CancelToken(); + final child = parent.child(); + + child.cancel(); + + expect(child.isCancelled, isTrue); + expect(parent.isCancelled, isFalse); + parent.throwIfCancelled(); + }); + + test('cancelling one child does not cancel its siblings', () { + final parent = CancelToken(); + final first = parent.child(); + final second = parent.child(); + + first.cancel(); + + expect(second.isCancelled, isFalse); + second.throwIfCancelled(); + }); + + test('cancel() is idempotent', () { + final token = CancelToken(); + token + ..cancel() + ..cancel(); + expect(token.isCancelled, isTrue); + expect(token.throwIfCancelled, throwsA(isA())); + }); }); } diff --git a/runtime/core/test/domain/run_test.dart b/runtime/core/test/domain/run_test.dart new file mode 100644 index 0000000..f3bfd0e --- /dev/null +++ b/runtime/core/test/domain/run_test.dart @@ -0,0 +1,136 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:alteri_one_core/alteri_one_core.dart'; +import 'package:test/test.dart'; + +void main() { + Run buildRun({String id = 'run-1', Set? taints}) => Run( + id: RunId(id), + profile: 'default', + mode: RunMode.chat, + prompt: 'hello', + budget: Budget.standard(), + capabilities: RunCapabilities( + const {}, + const {}, + const [], + MemoryRights.none, + ), + taints: taints ?? const {}, + cancel: CancelToken(), + ); + + group('Run', () { + test('constructs with defaults', () { + final cancel = CancelToken(); + final run = Run( + id: RunId('run-1'), + profile: 'default', + mode: RunMode.chat, + prompt: 'hello', + budget: Budget.standard(), + capabilities: RunCapabilities( + const {}, + const {}, + const [], + MemoryRights.none, + ), + cancel: cancel, + ); + expect(run.id.value, 'run-1'); + expect(run.profile, 'default'); + expect(run.mode, RunMode.chat); + expect(run.prompt, 'hello'); + expect(run.taints, isEmpty); + expect(run.cancel, same(cancel)); + }); + + test('taints can be provided', () { + final run = Run( + id: RunId('run-2'), + profile: 'default', + mode: RunMode.task, + prompt: 'do', + budget: Budget.standard(), + capabilities: RunCapabilities( + const {}, + const {}, + const [], + MemoryRights.none, + ), + taints: {UntrustedSource('web')}, + cancel: CancelToken(), + ); + expect(run.taints, contains(UntrustedSource('web'))); + }); + + test('a default run accepts a taint', () { + final run = buildRun(); + run.addTaint(UntrustedSource('web')); + expect(run.taints, hasLength(1)); + expect(run.taints, contains(UntrustedSource('web'))); + }); + + test('a pre-tainted run keeps its labels and accepts another', () { + final run = buildRun(taints: {UntrustedSource('web')}); + run.addTaint(UntrustedSource('fs')); + expect(run.taints, hasLength(2)); + expect(run.taints, contains(UntrustedSource('web'))); + expect(run.taints, contains(UntrustedSource('fs'))); + }); + + test('addTaint is idempotent', () { + final run = buildRun(); + run.addTaint(UntrustedSource('web')); + final labels = run.taints; + run.addTaint(UntrustedSource('web')); + expect(run.taints, hasLength(1)); + expect(labels, hasLength(1)); + expect(labels, contains(UntrustedSource('web'))); + }); + + test('taints cannot be cleared or extended through the getter', () { + final run = buildRun(taints: {UntrustedSource('web')}); + + expect( + () => run.taints.add(UntrustedSource('fs')), + throwsUnsupportedError, + ); + expect(() => run.taints.clear(), throwsUnsupportedError); + expect( + () => run.taints.remove(UntrustedSource('web')), + throwsUnsupportedError, + ); + expect(run.taints, contains(UntrustedSource('web'))); + }); + + test('copies the constructor set instead of aliasing it', () { + final callerSet = {UntrustedSource('web')}; + final run = buildRun(taints: callerSet); + + callerSet + ..add(UntrustedSource('fs')) + ..clear(); + + expect(run.taints, hasLength(1)); + expect(run.taints, contains(UntrustedSource('web'))); + }); + + test('addTaint leaves unrelated fields unchanged', () { + final run = buildRun(); + final runId = run.id; + final cancel = run.cancel; + final capabilities = run.capabilities; + final budget = run.budget; + run.addTaint(UntrustedSource('web')); + expect(run.id, same(runId)); + expect(run.profile, 'default'); + expect(run.mode, RunMode.chat); + expect(run.prompt, 'hello'); + expect(run.cancel, same(cancel)); + expect(run.capabilities, same(capabilities)); + expect(run.budget, same(budget)); + }); + }); +} diff --git a/runtime/core/test/infrastructure/hive_journal_test.dart b/runtime/core/test/infrastructure/hive_journal_test.dart new file mode 100644 index 0000000..ae8a96b --- /dev/null +++ b/runtime/core/test/infrastructure/hive_journal_test.dart @@ -0,0 +1,103 @@ +// Copyright 2026 The AlteriOne Authors. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:io'; + +import 'package:alteri_one_contracts/alteri_one_contracts.dart'; +import 'package:alteri_one_core/alteri_one_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('HiveJournal', () { + late Directory dir; + late HiveJournal journal; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('hive_journal_'); + journal = await HiveJournal.open(dir.path); + }); + + tearDown(() async { + await journal.close(); + await dir.delete(recursive: true); + }); + + test('seq increases across calls', () async { + final run = RunId('run-a'); + final s1 = await journal.started(run, 'tool', Risk.read); + await journal.denied(run, 'tool2', Risk.write, 'nope'); + expect(s1, 1); + final s2 = await journal.started(run, 'tool3', Risk.read); + expect(s2, 3); + }); + + test('persists across reopen', () async { + final run = RunId('run-a'); + final seq = await journal.started(run, 'tool', Risk.read); + await journal.finished(run, seq, InvocationStatus.succeeded, null); + await journal.close(); + journal = await HiveJournal.open(dir.path); + final invs = await journal.forRun(run); + expect(invs, hasLength(1)); + expect(invs.first.status, InvocationStatus.succeeded); + final next = await journal.started(run, 't', Risk.read); + expect(next, 2); + }); + + test('forRun filters by run', () async { + final r1 = RunId('run-1'); + final r2 = RunId('run-2'); + await journal.started(r1, 'a', Risk.read); + await journal.started(r2, 'b', Risk.read); + final list1 = await journal.forRun(r1); + final list2 = await journal.forRun(r2); + expect(list1, hasLength(1)); + expect(list2, hasLength(1)); + expect(list1.first.tool, 'a'); + expect(list2.first.tool, 'b'); + }); + + test('concurrent started calls get unique seq values', () async { + final run = RunId('run-c'); + final seqs = await Future.wait([ + for (var i = 0; i < 10; i++) journal.started(run, 'tool$i', Risk.read), + ]); + + expect(seqs.toSet(), hasLength(10)); + final invs = await journal.forRun(run); + expect(invs, hasLength(10)); + expect(invs.map((i) => i.seq).toSet(), seqs.toSet()); + + // The counter is durable before the step record, so a reopen continues + // past the highest seq instead of reusing one (storage.md, 15.2). + final highest = seqs.reduce((a, b) => a > b ? a : b); + await journal.close(); + journal = await HiveJournal.open(dir.path); + final next = await journal.started(run, 'after', Risk.read); + expect(next, greaterThan(highest)); + }); + + test('writes a denied record with status, risk, and reason', () async { + final run = RunId('run-deny'); + await journal.denied(run, 'ext__post', Risk.external, 'policy'); + + final inv = (await journal.forRun(run)).single; + expect(inv.tool, 'ext__post'); + expect(inv.status, InvocationStatus.denied); + expect(inv.risk, Risk.external); + expect(inv.error, 'policy'); + }); + + test('finished throws for an unknown seq', () async { + await expectLater( + journal.finished( + RunId('run-missing'), + 99, + InvocationStatus.succeeded, + null, + ), + throwsA(isA()), + ); + }); + }); +}