From 385691257badf649ac5b86a863c086b16af014f6 Mon Sep 17 00:00:00 2001 From: Dimoshka Date: Sat, 24 Oct 2026 08:09:47 +0000 Subject: [PATCH 1/7] feat(core): add the Run aggregate and HiveJournal --- docs/plan/M2.md | 33 +++++ runtime/core/lib/alteri_one_core.dart | 2 + runtime/core/lib/src/domain/run.dart | 50 +++++++ .../infrastructure/journal/hive_journal.dart | 140 ++++++++++++++++++ runtime/core/test/domain/run_test.dart | 52 +++++++ .../infrastructure/hive_journal_test.dart | 60 ++++++++ 6 files changed, 337 insertions(+) create mode 100644 docs/plan/M2.md create mode 100644 runtime/core/lib/src/domain/run.dart create mode 100644 runtime/core/lib/src/infrastructure/journal/hive_journal.dart create mode 100644 runtime/core/test/domain/run_test.dart create mode 100644 runtime/core/test/infrastructure/hive_journal_test.dart diff --git a/docs/plan/M2.md b/docs/plan/M2.md new file mode 100644 index 0000000..cce562a --- /dev/null +++ b/docs/plan/M2.md @@ -0,0 +1,33 @@ +# Milestone M2: domain and loop + +- Status: In progress (M2 step 1 landed in `feat/m2-run-aggregate`, draft) +- Specification: `docs/spec/domain.md` (sections 8–9), `docs/spec/storage.md` (section 15), `docs/spec/roadmap.md` (M2) +- ADR: — + +## Status / progress + +### 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 — In progress + +- 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`. + +## Outcome + +Run aggregate and journal implementation landed on `feat/m2-run-aggregate` (draft PR). Pending: wiring the journal into the run lifecycle and the agent loop (spec section 9). + +## Next steps + +- Wire the journal into the composition root and the loop. +- Agent loop (spec section 9) and the remaining M2 steps. diff --git a/runtime/core/lib/alteri_one_core.dart b/runtime/core/lib/alteri_one_core.dart index 9583b08..5abf50b 100644 --- a/runtime/core/lib/alteri_one_core.dart +++ b/runtime/core/lib/alteri_one_core.dart @@ -32,5 +32,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/domain/run.dart b/runtime/core/lib/src/domain/run.dart new file mode 100644 index 0000000..d5df463 --- /dev/null +++ b/runtime/core/lib/src/domain/run.dart @@ -0,0 +1,50 @@ +// 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 '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, + + /// Taint labels applied to the run. Defaults to empty (clean). + final Set taints = const {}, + + /// Cooperative cancellation token. + required final CancelToken cancel, +}) {} 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..c0ec8bf --- /dev/null +++ b/runtime/core/lib/src/infrastructure/journal/hive_journal.dart @@ -0,0 +1,140 @@ +// 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 { + HiveJournal._(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); + } + + @override + 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()); + 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': 'started', + 'at': now.toIso8601String(), + 'error': null, + }; + await steps.put(_key(runId, seq), jsonEncode(record)); + await steps.flush(); + await meta.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': 'denied', + 'at': now.toIso8601String(), + 'error': reason, + }; + await steps.put(_key(runId, seq), jsonEncode(record)); + await steps.flush(); + await meta.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/domain/run_test.dart b/runtime/core/test/domain/run_test.dart new file mode 100644 index 0000000..fa2fa35 --- /dev/null +++ b/runtime/core/test/domain/run_test.dart @@ -0,0 +1,52 @@ +// 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() { + 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'))); + }); + }); +} 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..5b1d232 --- /dev/null +++ b/runtime/core/test/infrastructure/hive_journal_test.dart @@ -0,0 +1,60 @@ +// 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'); + }); + }); +} From 5264a73316aa2cd25b35a6821b39bef9eb3c7fc7 Mon Sep 17 00:00:00 2001 From: Dimoshka Date: Mon, 26 Oct 2026 12:41:57 +0000 Subject: [PATCH 2/7] fix(core): journal status literals enum-first; mark M2 step 1 done --- docs/plan/M2.md | 4 ++-- .../core/lib/src/infrastructure/journal/hive_journal.dart | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/plan/M2.md b/docs/plan/M2.md index cce562a..0c0a1c1 100644 --- a/docs/plan/M2.md +++ b/docs/plan/M2.md @@ -1,6 +1,6 @@ # Milestone M2: domain and loop -- Status: In progress (M2 step 1 landed in `feat/m2-run-aggregate`, draft) +- Status: In progress (M2 step 1 landed in `feat/m2-run-aggregate`, draft; step 1 done) - Specification: `docs/spec/domain.md` (sections 8–9), `docs/spec/storage.md` (section 15), `docs/spec/roadmap.md` (M2) - ADR: — @@ -17,7 +17,7 @@ - `provider_api`, `sdk`, `client` remain scaffolds, to be filled in later milestones. - PR #40 merged to mark M1 done. -### M2 step 1 — In progress +### 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`. diff --git a/runtime/core/lib/src/infrastructure/journal/hive_journal.dart b/runtime/core/lib/src/infrastructure/journal/hive_journal.dart index c0ec8bf..b51b88a 100644 --- a/runtime/core/lib/src/infrastructure/journal/hive_journal.dart +++ b/runtime/core/lib/src/infrastructure/journal/hive_journal.dart @@ -28,7 +28,6 @@ final class HiveJournal implements JournalPort { return HiveJournal._(meta, steps); } - @override Future close() async { await meta.close(); await steps.close(); @@ -55,7 +54,7 @@ final class HiveJournal implements JournalPort { 'runId': runId.value, 'tool': tool, 'risk': risk.name, - 'status': 'started', + 'status': InvocationStatus.started.name, 'at': now.toIso8601String(), 'error': null, }; @@ -80,7 +79,7 @@ final class HiveJournal implements JournalPort { 'runId': runId.value, 'tool': tool, 'risk': risk.name, - 'status': 'denied', + 'status': InvocationStatus.denied.name, 'at': now.toIso8601String(), 'error': reason, }; From 5cba8135190961c065fb48c39643a5dbdf084afc Mon Sep 17 00:00:00 2001 From: Dmytro Prylutskyi Date: Sun, 8 Nov 2026 10:28:41 +0100 Subject: [PATCH 3/7] fix(core): use the Dart 3.13 constructor form in HiveJournal Use the primary-constructor form for the private constructor and make the Hive boxes private. Flush the sequence counter before writing the step record so a crash cannot reuse a seq, and cover the durability path and the denied/finished branches with tests. --- .../infrastructure/journal/hive_journal.dart | 39 +++++++++-------- .../infrastructure/hive_journal_test.dart | 43 +++++++++++++++++++ 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/runtime/core/lib/src/infrastructure/journal/hive_journal.dart b/runtime/core/lib/src/infrastructure/journal/hive_journal.dart index b51b88a..75f3725 100644 --- a/runtime/core/lib/src/infrastructure/journal/hive_journal.dart +++ b/runtime/core/lib/src/infrastructure/journal/hive_journal.dart @@ -12,13 +12,13 @@ import '../../domain/ports/journal_port.dart'; /// Hive-backed implementation of [JournalPort]. final class HiveJournal implements JournalPort { - HiveJournal._(this.meta, this.steps); + new _(this._meta, this._steps); /// Metadata box (sequence counter). - final Box meta; + final Box _meta; /// Lazy box holding journaled invocations. - final LazyBox steps; + final LazyBox _steps; /// Opens (or creates) a journal stored under [directory]. static Future open(String directory) async { @@ -29,15 +29,20 @@ final class HiveJournal implements JournalPort { } Future close() async { - await meta.close(); - await steps.close(); + await _meta.close(); + await _steps.close(); } - int get _seq => int.parse(meta.get('seq', defaultValue: '0')!); + int get _seq => int.parse(_meta.get('seq', defaultValue: '0')!); Future _nextSeq() async { final next = _seq + 1; - await meta.put('seq', next.toString()); + 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; } @@ -58,9 +63,8 @@ final class HiveJournal implements JournalPort { 'at': now.toIso8601String(), 'error': null, }; - await steps.put(_key(runId, seq), jsonEncode(record)); - await steps.flush(); - await meta.flush(); + await _steps.put(_key(runId, seq), jsonEncode(record)); + await _steps.flush(); return seq; } @@ -83,9 +87,8 @@ final class HiveJournal implements JournalPort { 'at': now.toIso8601String(), 'error': reason, }; - await steps.put(_key(runId, seq), jsonEncode(record)); - await steps.flush(); - await meta.flush(); + await _steps.put(_key(runId, seq), jsonEncode(record)); + await _steps.flush(); } @override @@ -96,27 +99,27 @@ final class HiveJournal implements JournalPort { String? error, ) async { final key = _key(runId, seq); - final existingRaw = await steps.get(key); + 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(); + 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) { + for (final key in _steps.keys) { final keyStr = key as String; if (!keyStr.startsWith(prefix)) { continue; } - final raw = await steps.get(keyStr); + final raw = await _steps.get(keyStr); if (raw == null) { continue; } diff --git a/runtime/core/test/infrastructure/hive_journal_test.dart b/runtime/core/test/infrastructure/hive_journal_test.dart index 5b1d232..ae8a96b 100644 --- a/runtime/core/test/infrastructure/hive_journal_test.dart +++ b/runtime/core/test/infrastructure/hive_journal_test.dart @@ -56,5 +56,48 @@ void main() { 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()), + ); + }); }); } From 14d2d12dce805c67dabfab9e005e5a7a850af5b7 Mon Sep 17 00:00:00 2001 From: Dmytro Prylutskyi Date: Sun, 8 Nov 2026 10:28:43 +0100 Subject: [PATCH 4/7] feat(core): make run taints read-only and add child cancel tokens Run exposes taints through an unmodifiable live view and accepts new labels only through addTaint. CancelToken.child() derives a token that inherits the parent's cancellation while cancelling independently, so one tool call can be abandoned without cancelling the run. --- runtime/core/lib/src/domain/cancel_token.dart | 20 ++++- runtime/core/lib/src/domain/run.dart | 38 ++++++++- runtime/core/test/domain/run_state_test.dart | 55 ++++++++++++ runtime/core/test/domain/run_test.dart | 84 +++++++++++++++++++ 4 files changed, 192 insertions(+), 5 deletions(-) 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 index d5df463..b264025 100644 --- a/runtime/core/lib/src/domain/run.dart +++ b/runtime/core/lib/src/domain/run.dart @@ -5,6 +5,8 @@ /// (docs/spec/domain.md, section 8.1). library; +import 'dart:collection'; + import 'budget.dart'; import 'cancel_token.dart'; import 'ids.dart'; @@ -42,9 +44,39 @@ final class Run({ /// Capabilities granted to the run. required final RunCapabilities capabilities, - /// Taint labels applied to the run. Defaults to empty (clean). - final Set taints = const {}, + /// 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/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 index fa2fa35..f3bfd0e 100644 --- a/runtime/core/test/domain/run_test.dart +++ b/runtime/core/test/domain/run_test.dart @@ -5,6 +5,22 @@ 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(); @@ -48,5 +64,73 @@ void main() { ); 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)); + }); }); } From 4ff99c7119f5cba6254c0295cfa08e7a01761b37 Mon Sep 17 00:00:00 2001 From: Dmytro Prylutskyi Date: Sun, 8 Nov 2026 10:28:45 +0100 Subject: [PATCH 5/7] feat(core): add the section 9 agent loop Add the application/agent seams (RunResult, ToolSet/ToolRegistry, AgentConfig, ApprovalService, ContextBuilder, AgentDeps), the fail-closed defaults, and runAgent. The loop journals policy denials and executed calls, runs tools concurrently on child cancel tokens, and ends in RunCompleted or RunBudgetExceeded. Export the new types from the package. --- runtime/core/lib/alteri_one_core.dart | 8 + .../src/application/agent/agent_config.dart | 16 + .../src/application/agent/agent_defaults.dart | 67 ++ .../lib/src/application/agent/agent_deps.dart | 37 + .../lib/src/application/agent/agent_loop.dart | 155 ++++ .../application/agent/approval_service.dart | 19 + .../application/agent/context_builder.dart | 16 + .../lib/src/application/agent/run_result.dart | 37 + .../src/application/agent/tool_output.dart | 83 ++ .../lib/src/application/agent/tool_set.dart | 42 + .../core/test/application/agent_fakes.dart | 295 +++++++ .../test/application/agent_loop_test.dart | 817 ++++++++++++++++++ .../test/application/tool_output_test.dart | 92 ++ .../core/test/application/tool_set_test.dart | 83 ++ 14 files changed, 1767 insertions(+) create mode 100644 runtime/core/lib/src/application/agent/agent_config.dart create mode 100644 runtime/core/lib/src/application/agent/agent_defaults.dart create mode 100644 runtime/core/lib/src/application/agent/agent_deps.dart create mode 100644 runtime/core/lib/src/application/agent/agent_loop.dart create mode 100644 runtime/core/lib/src/application/agent/approval_service.dart create mode 100644 runtime/core/lib/src/application/agent/context_builder.dart create mode 100644 runtime/core/lib/src/application/agent/run_result.dart create mode 100644 runtime/core/lib/src/application/agent/tool_output.dart create mode 100644 runtime/core/lib/src/application/agent/tool_set.dart create mode 100644 runtime/core/test/application/agent_fakes.dart create mode 100644 runtime/core/test/application/agent_loop_test.dart create mode 100644 runtime/core/test/application/tool_output_test.dart create mode 100644 runtime/core/test/application/tool_set_test.dart diff --git a/runtime/core/lib/alteri_one_core.dart b/runtime/core/lib/alteri_one_core.dart index 5abf50b..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'; 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/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)); + }); + }); +} From b353eac30a64bef402a2cb60bf56ac8270a33fea Mon Sep 17 00:00:00 2001 From: Dmytro Prylutskyi Date: Sun, 8 Nov 2026 10:28:47 +0100 Subject: [PATCH 6/7] feat(core): wire the journal and agent dependencies into DI Register the agent defaults and AgentDeps in the core module and add registerJournal, which opens a HiveJournal under a caller-supplied directory and registers it as both HiveJournal and JournalPort. Regenerate the injectable config and cover the bindings, the fail-closed defaults and the duplicate-registration guards. --- runtime/core/lib/src/di/core_module.dart | 39 +++++ runtime/core/lib/src/di/injection.config.dart | 25 +++ runtime/core/lib/src/di/injection.dart | 25 +++ runtime/core/test/di/injection_test.dart | 149 +++++++++++++++++- 4 files changed, 237 insertions(+), 1 deletion(-) 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/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'), + ), + ), + ); + }); + }); } From 3dacea9f4ed470e5814e2cdb88d8208a6333fb65 Mon Sep 17 00:00:00 2001 From: Dmytro Prylutskyi Date: Sun, 8 Nov 2026 10:28:49 +0100 Subject: [PATCH 7/7] docs(plan): record M2 progress and open questions Rewrite the M2 plan to the plan template with the completed steps ticked, record Q7-Q14 in the open-questions list, and add the section 9 implementation pointer to domain.md. --- docs/plan/M2.md | 85 ++++++++++++++++++++++++++++++------- docs/spec/domain.md | 2 + docs/spec/open-questions.md | 10 ++++- 3 files changed, 80 insertions(+), 17 deletions(-) diff --git a/docs/plan/M2.md b/docs/plan/M2.md index 0c0a1c1..43c9a04 100644 --- a/docs/plan/M2.md +++ b/docs/plan/M2.md @@ -1,33 +1,86 @@ # Milestone M2: domain and loop -- Status: In progress (M2 step 1 landed in `feat/m2-run-aggregate`, draft; step 1 done) -- Specification: `docs/spec/domain.md` (sections 8–9), `docs/spec/storage.md` (section 15), `docs/spec/roadmap.md` (M2) -- ADR: — +- 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`) -## Status / progress +## Acceptance criteria -### M0 — Done +- [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 +#### 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 +#### 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`. - -## Outcome - -Run aggregate and journal implementation landed on `feat/m2-run-aggregate` (draft PR). Pending: wiring the journal into the run lifecycle and the agent loop (spec section 9). - -## Next steps - -- Wire the journal into the composition root and the loop. -- Agent loop (spec section 9) and the remaining M2 steps. 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.