Skip to content

feat: D1 Durable Dana Conversation — restart-resumable ACP agent - #27

Open
ngoclam9415 wants to merge 64 commits into
developfrom
feat/acp-agent-session-kernel
Open

feat: D1 Durable Dana Conversation — restart-resumable ACP agent#27
ngoclam9415 wants to merge 64 commits into
developfrom
feat/acp-agent-session-kernel

Conversation

@ngoclam9415

Copy link
Copy Markdown
Contributor

Summary

Dana now streams multi-turn text conversations through dana-acp and automatically resumes the same session after the ACP process restarts. This is the first delivery (D1) of the ACP AgentSession kernel — a Session Journal backed by SQLite/PostgreSQL is the sole durable authority for every turn.

What shipped

Area Files
Journal models dana/core/session/{models,protected_state}.pyOwnerScope, JournalFact, FactType, ProtectedStateCodec (AES-GCM + HKDF + AAD)
DB adapters dana/core/session/journal/{sqlite,postgres,protocol,schema,models}.py — one contract, two real databases, optimistic concurrency
Projections dana/core/session/projections/{conversation,host_events}.py — ConversationView (committed-turn gating) + HostEvent stream
AgentSession dana/core/session/agent_session.py — serialized text turns, streaming seam, bounded flush, crash-safe journaling
Crash recovery + migration dana/core/session/legacy_timeline_migration.py — interrupted-turn detection + idempotent legacy Timeline import
ACP stdio agent dana/apps/acp/initialize, session/new, session/load, session/resume, session/prompt, session/cancel
Health checks dana/core/session/health.py — redacted operational report
Docs Architecture, ACP config, storage/migration/rollback, briefing, changelog

Key invariants

  • Input durability: TURN_STARTED + USER_CONTENT_FINAL appended before the model call
  • One terminal per turn: ASSISTANT_CONTENT_FINAL + terminal in one atomic batch
  • No post-terminal mutation: nothing appended after the terminal fact
  • Same-session concurrency: second prompt while active returns busy
  • Interrupted turns: partial output host-visible but excluded from ConversationView
  • ACP isolation: acp.* imports confined to dana/apps/acp/; STAR core untouched

Test results

  • Session + integration: 210 passed, 16 skipped (PostgreSQL without DSN)
  • Full unit suite: 2087 passed, 0 failures
  • Ruff: clean on all new code

Dependencies added

  • agent-client-protocol>=0.10,<0.11
  • aiosqlite>=0.20.0
  • asyncpg>=0.30.0

What's NOT changed

  • dana-code, adana, dana-repl CLIs remain on legacy Timeline (incremental migration)
  • Existing STARAgent.query() / aquery() source-compatible
  • Tools, permissions, model switching, MCP, attachments — deferred to D2–D6

Console companion PR

Branch feat/acp-agent-session-restart in dana-console adds sessionStorage session-ID persistence + session/load resume on reconnect.

Design spec

Approved at docs/superpowers/specs/2026-07-07-acp-star-adapter-design.md.

Add DanaACPAgent implementing the ACP Agent protocol over stdio JSON-RPC,
enabling dana-console to connect to Dana as a Custom ACP Agent.

- dana/apps/acp/agent.py: DanaACPAgent translating ACP calls (initialize,
  session/new, session/load, session/resume, session/prompt, session/cancel)
  to AgentSession operations, streaming HostEvents back as session_update
  notifications
- dana/apps/acp/translation.py: HostEvent → ACP update chunk translation
  (ACP types never enter STAR core)
- dana/apps/acp/__main__.py: entry point with stderr-only logging
- dana/__init__/init_environment.py: redirect structlog to stderr so stdout
  stays clean for JSON-RPC frames
- pyproject.toml: dana-acp console script entry point
- tests/integration/test_acp_agent.py: 14 in-process + subprocess tests
  covering load capability, replay-before-return, chunk streaming, burst
  ordering, busy, cancel, malformed content, stderr/stdout discipline
Untrack /sprint/, /v2/, CLAUDE.md (local-only working docs, consistent with existing AGENTS.md/.claude/.opencode ignores). CLAUDE.md removed from repo; local copy retained via gitignore.
Per-agent intercept-capable EventBus: first-wins aggregation, sync+async handlers, raise isolation, emit_sync via Misc.safe_asyncio_run. Lazy mount on BaseSTARAgent. Non-dict results warned+skipped. 17 tests.
.agents/, .codegraph/, .codex/, .superpowers/, memories/, tests/unit/core/guard/ — local tool artifacts (consistent with .claude/.opencode).
_build_native_tools_if_supported now returns early if schemas already built. Rebuilding every build_prompt re-ran inspect.signature on every resource method under the tracing chain, exhausting the recursion budget on long sessions (librarian console crash). Structural deps don't change per turn -> build once. Adds 2 tests.
Milestone M3 (longest pole of v2.0 extensibility backbone). Wires the S1
EventBus into both tool-execution paths and adds a deny-only PermissionPolicy.

- ext/operation.py: Operation + ToolIdentity (thin, read-only via MappingProxyType)
- ext/permission.py: PermissionPolicy deny-only (a tool_call subscriber)
- ext/guard.py: reference rm-rf + protected-path policy (S4 discovery target)
- tool_executor.py: emit tool_call/tool_result around dispatch in both single-
  call paths; split out _dispatch_single_call[_async] (dispatch NOT merged)
- runtime/{protocols,__init__}.py: remove dead ToolHookProtocol/ApprovalProtocol
  scaffold + constructor params hooks/approval (never wired)

Adversarial review fixes: non-dict tool_result modify no longer crashes the
batch (isinstance guard + warn); guard substring rules str()-coerce (defeats
list-arg bypass); strict-bool block (is True); Operation.arguments immutable.

Tests: 27 new (S3.1-S3.15 + 7 adversarial fix-regressions). Regression green:
tests/unit + tests/integration 1614 passed, 37 skipped, 1 xfailed.
Milestone M2. Emits see_end/think_end/act_end/reflect_end around the STAR
phases in query()/aquery() so handlers can observe, modify, or block each
phase. STAR contract (_see/_think/_act/_reflect) unchanged.

- base_star_agent.py: _emit_phase[_async] helpers + per-phase block/modify
  wiring in _do_query/_do_aquery; reflect_end emit in the reflect wrappers.
- Orchestrator-based wiring (not scatter-site): STARAgent._think/_act_async
  broadcast inline without super(), so base-site wiring would miss them.
- Fix latent S1 bug: event_bus property used getattr(self,_event_bus,None)
  but STARAgent.__getattr__ returns a magic-method stub for any unknown attr,
  so the bus was never created on the real agent. Now reads self.__dict__.
- Adversarial fixes: per-phase exit uses EXIT_FLAG is True (not
  _do_exit_star_loop, avoiding the empty-dict false-exit quirk); act_end
  block sets phase_blocked (skips reflect, prevents repeat); non-dict modify
  ignored + warned.

Trade-off: broadcast fires before emit, so legacy broadcast observers see the
pre-modify result (accepted for minimal blast radius; modify still changes the
result for later phases).

Tests: 10 new (T2.1-T2.8 + 2 adversarial). Regression: tests/unit +
tests/integration 1624 passed, 37 skipped, 1 xfailed (the 9 done-flag-autonomy
tests caught the event_bus bug pre-fix).
Milestone M4 — completes the v2.0 extensibility backbone (M1-M4 all shipped).

Drop-in Python extensions discovered from ~/.dana/extensions/ (global, always)
and .dana/extensions/ (project, trust-gated via DANA_TRUST_PROJECT_EXTENSIONS)
and bound to the agent's EventBus via a setup(agent) factory using agent.on().

- ext/extensions.py: ExtensionManager — discover/load/reload + LoadReport.
  * Per-agent, lazy via agent.extensions (__dict__ storage, same __getattr__
    lesson as S1/S2).
  * Loader bypasses the pyc cache (read_text+compile+exec): SourceFileLoader
    keys .pyc on (mtime,size) so a same-byte-size edit within 1s would exec
    stale code — fatal for hot-reload correctness.
  * Reload: unsub tracked handlers, pop stale sys.modules, re-exec, emit
    SESSION_RELOAD. Must run at idle (S1 Finding A).
  * Sub tracking via wrapping bus.subscribe during setup (try/finally).
- base_star_agent.py: agent.on alias + extensions property + load_extensions/
  reload_extensions delegates. NOT auto-loaded at construction (host calls it;
  zero regression risk to agent init).
- Trust gate: global = user's home (trusted); project = explicit flag.

Adversarial fixes: failing setup rolls back partial handler registrations
(transactional; was a reload leak); reload pops stale sys.modules entries.

Tests: 10 new (T4.1-T4.8 + 2 adversarial). Regression: tests/unit +
tests/integration 1634 passed, 37 skipped, 1 xfailed.
Concise showcase of the intercept-capable EventBus: drop-in extensions
(~/.dana/extensions/*.py), the setup(agent)/agent.on contract, the
block/modify handler shapes, and the 3 concrete examples (rm-rf guard,
arg rewrite, STAR observer).
feat: v2.0 extensibility backbone (M1-M4)
…ce in _get_or_create_worker, remove dead _InFlight.wait()
- Add CancellationTree with cascade/detach/keep ownership semantics
- Add CancellationNode with acknowledged/timeout/effect-unknown outcomes
- Add kill escalation (force-kill all descendants regardless of ownership)
- Extend FactType enum with D2 tool lifecycle facts (non-terminal + terminal)
- Add comprehensive tests: cancellation matrix (6 contexts), outcome
  distinction, terminal fact enforcement, serialization, edge cases
- Add DurableJobManager with handoff request/confirm/fail lifecycle
- Add DurableJobRecord with status tracking (handoff_requested, running,
  completed, failed, cancelled, handoff_failed)
- Add cascade/detach integration: RUNNING jobs survive parent cancellation,
  HANDOFF_REQUESTED jobs fail on parent cancel
- Add serialization round-trip for records and manager state
- Add comprehensive tests: lifecycle, cascade/detach, edge cases,
  crash-before-handoff, serialization
- Add tool lifecycle HostEventTypes (TOOL_REQUESTED, TOOL_STARTED,
  TOOL_PROGRESS, TOOL_RESULT, TOOL_FAILURE, TOOL_ACKNOWLEDGED,
  TOOL_TIMED_OUT, TOOL_EFFECT_UNKNOWN, TOOL_CANCELLATION_REQUESTED,
  TOOL_AUTHORIZED_OR_DENIED, THOUGHT)
- Extend host_events.py fact-to-event mapping for all D2 tool facts
- Extend ACP translation with thought, tool-call, tool-update, result,
  and cancellation state mappings
- Add AgentSession tool lifecycle wiring: emit_thought, journal_tool_*,
  execute_tool_call with full lifecycle
- Add rollback flag (use_legacy_executor) for non-ACP host fallback
- Add comprehensive unit tests for all five ACP states and tool lifecycle
Migration gate (spec §Dependencies and Gates): official-protocol parity
achieved + cleanup tests pass. Removed:
- dana/lib/resources/mcp/ (MCPClientResource, BrightQueryResource,
  GitHubMCPResource, SlackMCPResource)
- dana/lib/resources/mcp_client.py (duplicate of mcp/mcp_client.py)
- Updated dana/lib/resources/__init__.py to remove MCP re-exports

All 678 unit tests pass (8 new MCP tests + 670 existing).
- MCPCatalogAdapter: discover MCP tools, convert to namespaced ToolCatalogEntry,
  deterministic collision detection, invalidation, and re-discovery
- MCPLease/MCPLeaseManager: session lease lifecycle (pending/active/failed/degraded/released),
  required-lease failure stops preflight, optional-lease failure degrades,
  restore from persisted state (session load)
- MCPConfig/load_mcp_config: JSON config loading with env allowlist,
  rollback via mcp_enabled flag, filter_env_for_server
- 57 new tests covering all ACs and edge cases
- Updated mcp/__init__.py exports

AC #1: Deterministic collisions
AC #2: Dynamic catalog invalidation
AC #3: Allowlisted environment enforced
AC #4: Configuration loading + rollback
- ContentNormalizer normalizes text/image/embedded_resource/file_resource blocks
- Validation module enforces MIME type, size limits, and path-safety checks
- ArtifactStore provides hash-based dedup with OwnerScope isolation
- ArtifactRetentionManager tracks independent retention policies
- New FactTypes: ARTIFACT_REFERENCE, ARTIFACT_DELETED
- NewJournalFact extended with artifact_refs field
- SQLite and Postgres adapters pass through artifact_refs on append
- Full test coverage: 63 new tests (validation, normalizer, store, retention)
…store

- Empty payload, zero-byte file, unsupported MIME, symlink traversal
- Concurrent duplicate uploads, large content
- Missing artifact during sweep, retention expiry race
…ync, add SIGKILL fallback and waitpid

fix(D6): return content_blocks_payload from _normalized_blocks_to_text_blocks, pass to session.prompt, fix dead D6 multimodal handlers in translation
Uncommitted D2/D4 implementation artifacts on the
feat/acp-agent-session-kernel working tree. Committing to clean the
tree before Sprint 3 (dana-code on AgentSession) begins.

- dana/config.py: ModelTargetConfig + Config.models (D4 model catalog config)
- dana/core/tool/tool_executor.py: tool_catalog param + ADR-004 catalog
  fast-path dispatch (D2)
- dana/core/tool/identity.py: check_collision() convenience wrapper (D2)
- dana/core/runtime/selector.py: RuntimeRegistry.build_switcher wiring for
  ModelSwitcher (D4) -- build_provider/apply_switch are placeholders
- tests/integration/test_acp_agent.py +239 (D5/D6)
- tests/unit/core/session/test_conversation_projection.py +171 (D6)

849 passed; 1 pre-existing unrelated failure
(test_mcp_cleanup::test_reap_child_pids -- OS PID-reaping flake).

Sprint 2 stories are done in the vault (2026-08-07).
…ridge (Wave 1)

Wire dana-code CLI onto the host-neutral AgentSession STAR core in-process
(Option B). Two execution paths selected by DANA_CODE_AGENTSESSION_ENABLED
(default on):

D7.1 — Async REPL + AgentSession construction (code_app.py):
- AgentSession path: asyncio.run drives an async REPL; _initialize_session
  builds a real AgentSession (journal, OwnerScope, SESSION_CREATED fact);
  _converse_async does `async for event in session.prompt([TextBlock]):
  renderer.handle_host_event(event)`, catching SessionBusy.
- Legacy path: DanaCodingAgent + renderer-as-Notifiable, unchanged (rollback).
- ADR-001 honored: the AgentSession path has zero STAR core imports (DanaCodingAgent
  is lazy-imported only inside the legacy func).

D7.2 — HostEvent -> RichCLIRenderer bridge (rich_cli_renderer.py + host_event_adapter.py):
- New host_event_adapter.py: pure dispatcher (in-process analog of ACP
  host_event_to_acp_update), maps every HostEventType to a Rich component handler.
- RichCLIRenderer.handle_host_event + 12 component handlers reusing existing
  spinner/stream-display/tool-card/result-panel/Live machinery + degradation paths.

agent_session.py: agent_factory now optional + default_agent_factory() (lazy
STARAgent import) so host adapters avoid importing STAR core. Backward-compatible
(ACP passes its own factory -> no-op).

Tests: 17 new (8 code_app + 9 adapter), no live LLM. Regression 858 passed
(ACP integration included); 1 known pre-existing flake (test_mcp_cleanup).

Code review: SHIP_WITH_CONDITIONS -> uat. Conditions folded into D7.3:
TURN_INTERRUPTED handling, repo teardown, public session_id/version props,
truthful Ctrl-C cancel-watcher, + the missing set_policy_evaluator/owner_scope
accessors (pre-existing Sprint 2 D3 debt).
…ist import

Addresses code-review findings (SHIP_WITH_CONDITIONS) on feaa043:

- I2 (Important): _converse_async now catches (KeyboardInterrupt,
  asyncio.CancelledError) and explicitly `await gen.aclose()` so the
  AgentSession prompt-generator's `async with` lock releases promptly on
  mid-turn Ctrl-C. Under asyncio.run (Py 3.11+) SIGINT surfaces as
  CancelledError, not KeyboardInterrupt — both caught; re-raise for clean
  shutdown. Truthful TURN_CANCELLED + interrupt-and-continue UX deferred
  to the D7.3 cancel-watcher (ADR-005).
- M2 (Minor): default_agent_factory defaults DANA_LLM_PROVIDER/DANA_MODEL
  to "openai"/"gpt-5" (was raw None), consistent with the CLI banner.
- M3 (Minor): hoist render_host_event/cancellation_outcome imports to
  module level in rich_cli_renderer.py (was imported inside the render
  lock on every call).

Remaining: I1 (runtime smoke of live dana-code REPL) + M1 (_current_version
private reach-in) — next session.

208 passed (17 Wave-1 + 191 ACP/session regression); ruff clean.
_run_agentsession opened an aiosqlite journal connection in
_initialize_session but never closed it. On asyncio.run shutdown the
abandoned aiosqlite worker thread caused "Event loop is closed" errors
and could hang teardown (the root cause behind the earlier smoke hang).

Add _close_repo() (await self._repo.close()) called from a finally:
around the REPL loop. SQLiteJournalRepository.close() exists (sqlite.py:458).

tmux smoke verified: dana-code starts (banner + ❯ prompt), responds to
/exit, and the tmux session ends cleanly — no shutdown hang.

8 Wave-1 tests pass; ruff clean.
…ixes empty turn

default_agent_factory passed llm_provider/model explicitly, which bypassed
STARAgent's config-manager resolution. The resulting LLM client was
misconfigured (esp. azure, which needs AZURE_OPENAI_ENDPOINT/DEPLOYMENT
resolved by config_manager) → aquery_text_stream yielded nothing → turns
rendered no assistant content (silent empty completion).

Fix: mirror dana.apps.acp.agent._default_agent_factory — pass NEITHER
llm_provider NOR model; let STARAgent/config_manager resolve provider, model,
api key, and azure endpoint/deployment from env/config. (Reverts the earlier
M2 "default to openai/gpt-5" change, which treated a symptom, not the cause.)

tmux smoke (azure · gpt-5.4) now renders a real LLM response end-to-end:
  ❯ say hi in one word
  How can I assist you today?

Turn path verified. 208 passed (Wave-1 + ACP/session regression); ruff clean.
show_user_message echoed `❯ <text>` on USER_MESSAGE, but prompt_toolkit
already renders the typed input → visible duplicate (`❯ say hi` twice).

Add `echo_user_message` flag to RichCLIRenderer (default False — off, since
the interactive prompt already shows the input). Enable for future
replay/non-interactive consumers that need to surface the user turn.

tmux smoke: `say hi` now appears once (was twice); response still renders.
17 Wave-1 tests pass; ruff clean.
_initialize_session manually set session._current_version to align with the
persisted SESSION_CREATED fact, but _prepare_agent already sets
_current_version from journal facts (max sequence) before the first append —
so the manual line was redundant AND reached into private state.

Removed; _prepare_agent handles version alignment lazily on the first turn.

tmux smoke (azure, 25s): turn still renders. 8 Wave-1 tests pass; ruff clean.
…nfra)

AgentSession was missing 5 public accessors (set_policy_evaluator,
policy_evaluator, owner_scope, session_id, version) that both ACP
(dana/apps/acp/agent.py:277,317,350-353) and the dana-code CLI (D7.3)
depend on — the ACP permission path raised AttributeError at runtime
(latent; not hit by current tests).

Also fix a latent ImportError in ACP new_session: it imported
SQLiteGrantStore from dana.core.policy.grants, but the class lives in
dana.core.policy.store_sqlite (grants.py has 0 references). Same wrong
import is the root cause of the D7.3 stash's 'BROKEN (ImportError)'.
… flags, cancel-watcher

Wire the D2-D6 capability surface into dana-code (in-process AgentSession):

- permissions.py: CLIPermissionAdapter (AC #2) — in-process analog of ACP
  session/request_permission; evaluates via PolicyEvaluator (ADR-006
  precedence), prompts the terminal user, persists durable grants.
- commands/__init__.py (AC #3, #6): extract /help /compact /status /reset;
  add /model (busy-reject via session._lock.locked(), atomic switch via
  ModelSwitcher) and /permissions; use public AgentSession accessors.
  /reset -> journal semantics (close repo + re-init session).
- code_app.py: wire grant_store + evaluator + set_policy_evaluator in
  _initialize_session (gate DANA_CODE_PERMISSION_PREFLIGHT_ENABLED);
  split _handle_command into _async (AgentSession) / _legacy (sync);
  close the in-memory grant_db on exit (avoids shutdown hang).
- Ctrl-C -> TURN_CANCELLED (ADR-005): cooperative cancel via
  session.cancel() + drain; mid-turn interrupt terminalizes truthfully
  (turn_cancelled fact) and RESUMES the REPL; between-turns Ctrl-C
  continues. Add TURN_INTERRUPTED to host_event_adapter TURN_TERMINAL_KIND.
- code_capabilities.py (AC #1, #4, #5, #6): DANA_CODE_*_ENABLED rollback
  flags (permission preflight, model switch, tool catalog, MCP, multimodal).

tmux smoke (azure): turn renders, /status shows live state, /model lists,
/reset mints new session, mid-turn Ctrl-C -> turn_cancelled + resume,
/exit CLEAN. 25 new tests green; 2660 total (minus known OS-PID flake).
… module

Move ACP's _normalized_blocks_to_text_blocks (agent.py:656) to
dana/core/content/blocks.py as normalized_blocks_to_text_blocks so both
dana-acp and dana-code share one faithful multimodal block conversion
(ADR-009). ACP now imports the shared helper; behaviour unchanged.
AC #5 (D6 multimodal): _build_prompt_blocks parses @/path attachments in
a message into content blocks (image extensions → image block; other files
→ file_resource), validates provider capability (ADR-009), and passes
content_blocks to session.prompt — mirroring ACP's _acp_prompt_to_normalized_blocks
→ normalized_blocks_to_text_blocks flow. Gated by DANA_CODE_MULTIMODAL_ENABLED.
Convention documented in /help.

M2 (ADR-002 durability): /model now journals a MODEL_CHANGED fact after a
successful atomic switch (mirrors ACP session/set_session_model, agent.py:460-489)
via session.append_fact. Stub builders match ACP (SimpleNamespace — real
provider construction deferred). /model no-arg Current now env-falls-back
like /status. Failed switches do not journal.

Tests: +11 (MODEL_CHANGED journaled/failed-no-journal, env-fallback, 8
multimodal _build_prompt_blocks cases, shared helper). 2670 passed (1 known
flake test_reap_child_pids). ruff clean. tmux smoke (azure): turn renders,
/model Current=azure/gpt-5.4, /status live, /exit CLEAN.
…nt identity) — P0 part 1

Both default_agent_factory (agent_session.py) and ACP _default_agent_factory
(agent.py) built a bare text-only STARAgent (enable_assistant=False,
identity_override=None) with a 5659-char GENERIC STAR system prompt and no
coding identity — so it could not answer coding questions (every prompt got a
generic greeting; the d6b73d6 fix only corrected the empty stream, not the
non-functional agent).

Build DanaCodingAgent instead (coding IDENTITY system prompt) with
provider/model read from env, mirroring the legacy _initialize_legacy_agent
path that is verified to answer real prompts. DanaCodingAgent handles an
explicit llm_provider/model correctly (legacy proves it), so this does NOT
reintroduce the d6b73d6 misconfigured-azure-client empty-stream bug (smoke
confirms non-empty stream).

NECESSARY BUT INSUFFICIENT for turn-path parity: the AgentSession turn path
(AgentSession.prompt -> aquery_text_stream) is text-only and bypasses the full
STAR loop, so the model still does not engage with the user question the way
the legacy query() STAR loop does (A/B confirmed: legacy answers 's[::-1]...';
AgentSession still gives 'What would you like me to do in this repo?'). Full
parity requires AgentSession.prompt to drive the streaming STAR loop
(aquery_stream + StreamEvent->HostEvent mapping) — a separate, deeper change.

Tests: 2670 passed (1 known flake test_reap_child_pids). ruff clean.
…tream + StreamEvent->HostEvent) — fixes non-functional turns (P0 part 2)

AgentSession.prompt() used aquery_text_stream — a stripped text-only path
that bypasses the STAR loop — so the model never engaged with the user's
prompt (every question got a canned greeting). Rewire it to drive the full
streaming STAR loop (aquery_stream: see/think/act with tool-calling +
reflection + JSON response parsing) and map each StreamEvent to a HostEvent
+ journal the corresponding fact, reusing the existing journal helpers:

  TEXT_DELTA      -> ASSISTANT_CONTENT_CHUNK (chunk-buffer/flush preserved)
  THINKING        -> THOUGHT (live-only, emit_thought)
  TOOL_CALL_START -> TOOL_REQUESTED + AUTHORIZED + STARTED
  TOOL_RESULT     -> TOOL_RESULT terminal
  ERROR           -> TURN_ERROR terminal
  DONE            -> TURN_COMPLETED terminal (ASSISTANT_CONTENT_FINAL + batch)

Preserved: TURN_STARTED/USER_CONTENT_FINAL durability (content_blocks for
D6), asyncio.Lock serialization + SessionBusy, cooperative cancel()
(TURN_CANCELLED — checked between events since aquery_stream doesn't honor
cancel_event), journal version bookkeeping, last_terminal, the terminal
batch append. Removed the redundant _add_user_message_to_timeline call
(STARAgent._see adds the caller_message to the timeline itself).

Shared fix: ACP uses AgentSession.prompt too, so ACP turns are fixed as
well. Bonus: the STAR loop makes the agent's native tools reachable
(AC #1) — 'Which tools do you have?' now lists Grep/bash__execute/Task/
todo__todo_write/Skill/etc.

Test fakes (FakeAgent in test_agent_session + test_acp_agent) gained an
aquery_stream stand-in yielding TEXT_DELTA + DONE.

Verified: 2670 passed (1 known flake test_reap_child_pids). ruff clean.
Live tmux smoke (azure, real questions): 'Which tools do you have?' -> real
tool/skill listing; 'Write a Python one-liner to reverse a string' ->
print('hello'[::-1]). /exit CLEAN. Legacy path A/B still correct.
…uivalence, mocked provider)

Wave 3 parity harness: drives the same scripted turn through both the
dana-code in-process AgentSession path and the dana-acp JSON-RPC path,
asserting the logical event streams are equivalent (AC #1+#2).

Both paths call AgentSession.prompt() (streaming STAR loop via aquery_stream,
P0 part 2 f25fde3). The CLI path captures HostEvents directly; the ACP path
translates each HostEvent to a session_update via host_event_to_acp_update.
Parity assertion: ACP update-kind sequence == forward translation of the
CLI HostEvent sequence.

Coverage (AC #3): text turn, tool-call turn (tool lifecycle), thought,
cancellation (TURN_CANCELLED / stop_reason=cancelled), model switch
(MODEL_CHANGED journaled + busy-reject in both), permission (CLI adapter
deny/allow; ACP parity xfailed with documented findings).

AC #4: mocked provider (ScriptedAgent) — no live LLM, no network.

Findings filed (not fixed — tests-only story):
- FINDING 1: ACP PermissionOptionKind is typing.Literal, not enum →
  AttributeError on ALLOW_ONCE (agent.py:332+).
- FINDING 2: ACP RequestPermissionResponse missing required 'outcome' field
  (agent.py:367).
- FINDING 3: live-tool-call permission preflight not exercised —
  AgentSession.prompt() hardcodes authorized=True on TOOL_CALL_START,
  does not consult policy_evaluator. D7.5 scope.

13 passed, 2 xfailed. Full suite: 2683 passed, 3 xfailed, 1 known flake.
…on xfails

Rewrite request_permission to the installed acp outcome schema:
RequestPermissionResponse(outcome=AllowedOutcome(selected, option_id)
| DeniedOutcome(cancelled)). DENY -> DeniedOutcome (denied_reason in
response field_meta); ALLOW -> AllowedOutcome (allow_always if a durable
grant matched, else allow_once); NEEDS_PROMPT -> DeniedOutcome fail-closed
(ADR-006; the ACP host surfaces the reason + offers a grant/mode-change,
then re-requests). No-evaluator -> pre-authorize a single use.

Flip the 2 D7.4 parity xfails (test_permission_acp_deny/allow_parity) to
plain passing asserts against the new outcome shape (real
RequestPermissionRequest + ToolCallUpdate + PermissionOption list).
…wrapper (AC #4)

D7.5 Piece C — MCP tools reachable from a live host turn (AC #4).

Per D7.5 Decision 2 (STAR-loop native tools canonical; D2 ToolCatalog
deferred), MCP tools are surfaced as ONE @named_tool('call_mcp_tool')
async resource method. The model calls call_mcp_tool(tool_name, arguments);
the wrapper dispatches to the per-server MCPExecutionAdapter. Avoids the
per-tool native-wrapper schema impedance (native schemas from Python type
hints vs MCP arbitrary JSON inputSchema) without touching deferred D2.
Per-tool UX deferred to D7.6 D2 Catalog Migration. No policy gating (D7.6).

- dana/core/mcp/dispatch_wrapper.py (new): MCPDispatchResource + build_mcp_dispatch_resource + MCPWiring
- dana/core/mcp/config.py: load_mcp_config_from_env (DANA_MCP_SERVERS JSON)
- dana/core/session/agent_session.py: _wire_mcp_tools (in _prepare_agent, gated) + dispose_mcp
- dana/apps/code/code_app.py: dispose_mcp on REPL exit
- tests/unit/core/mcp/test_mcp_dispatch_wrapper.py (24 tests)

Verified: 2709 passed (1 known flake test_reap_child_pids), ruff clean, two-question tmux smoke green.
… permission preflight (AC #1, AC #2-live, hard-deny)

D7.6 — D2 Catalog Migration (middle path): build a minimal native-tool
ToolCatalog so the permission policy can classify tools, WITHOUT rerouting
the STAR loop through the D2 engine (D7.5 Decision 2 — native tools stay
canonical; the catalog feeds the policy classifier only).

Pieces:
- native_catalog.py: static effect-classification table for the 14 native
  tools (Read/Grep/Glob/read_tool_result/bash__get_task_output/bash__list_tasks/
  TaskOutput/todo__todo_write/Edit/Write/Task/bash__execute/bash__kill_task/
  Skill) + call_mcp_tool; build_native_tool_catalog. All known native tools
  are is_sensitive=False (flow to mode/grant/prompt; NEEDS_PROMPT -> proceed
  per the D7.5 adjusted ruling). Unknown/unlisted tools -> is_sensitive=True
  -> hard-denied (fail-cautious; a new tool MUST be classified explicitly).
- ToolCatalog: add a per-turn pinned 'version' field (AC #1 stable identity +
  per-turn versioned; ADR-004).
- AgentSession: build the catalog per turn (_build_tool_catalog, gated by
  DANA_CODE_TOOL_CATALOG_ENABLED) + register a TOOL_CALL EventBus hook
  (_register_policy_hook, gated by DANA_CODE_PERMISSION_PREFLIGHT_ENABLED).
  The hook (_on_tool_call) reconstructs a policy Operation from the bus
  Operation + the catalog, evaluates via policy_evaluator, and returns
  {'block': True} ONLY on PolicyDecision.DENY; ALLOW/NEEDS_PROMPT proceed.
  Pass-through when no catalog (preflight-on + catalog-off must NOT deny
  everything — P0 guard). Teardown unregisters the hook.
- Feed the catalog to build_policy_operation in ACP request_permission +
  CLIPermissionAdapter (catalog_getter) so all surfaces classify consistently.

Verification: 2725 tests pass (+16 new; 1 known flake test_reap_child_pids);
ruff clean; two-question tmux smoke (azure) green — 'Which tools do you
have?' -> real tool list; 'reverse a string' -> s[::-1]; /exit CLEAN. No
P0 regression (normal native tools classify non-sensitive -> proceed).

Unblocks: D7.3 AC #2-live (permission preflight in the turn), AC #1
(catalog-backed stable identity + per-turn version), hard-deny enforcement
(unknown tools + destructive-on-protected-path).

Filed findings (pre-existing, not fixed here): (1) the hard policy rm -rf
rule keys off 'bash_tool' but the native tool is 'bash__execute' -> never
fires; (2) the protected-path rule covers {DELETE, MODIFY} but not CREATE
-> Write on .env is not hard-denied.

Do NOT edit vault files; orchestrator handles status + Dev Agent Record.
…; CREATE added to protected-path destructive set

Two pre-existing hard-policy gaps filed during D7.6 (Decision Log):
1. bash rm -rf defense-in-depth rule keyed off 'bash_tool' (dead — the
   actual native tool is 'bash__execute') -> the rule never fired. Now keys
   off 'bash__execute'.
2. Protected-path destructive set was {DELETE, MODIFY} — Write (CREATE) on
   .env/node_modules was NOT hard-denied. Added EffectKind.CREATE so
   create/overwrite on protected paths is blocked.

Tests: updated test_default_policy_blocks_rm_rf / allows_safe_bash to the
real 'bash__execute' name; added test_default_policy_blocks_destructive_on_
protected_path covering DELETE/MODIFY/CREATE on .env + node_modules + a
non-protected-path allow case. 2726 passed (1 known flake test_reap_child_pids).
The live TOOL_CALL permission hook (D7.6) returned None (proceed) on
PolicyDecision.NEEDS_PROMPT. Now the CLI prompts the user interactively:

- AgentSession.set_permission_prompt_callback(callback): a host-provided
  async (operation) -> PermissionVerdict. The _on_tool_call hook, on
  NEEDS_PROMPT, calls it; allowed -> proceed, denied -> block (journal
  denied, do not execute). No callback (ACP) -> proceed (request_permission
  is the ACP resolution surface).
- CLIPermissionAdapter.prompt_and_persist(op): public entry for the hook
  (prompts allow once/always/deny once/always + persists a durable grant on
  'always'). The sync input() prompt runs off-thread (asyncio.to_thread) so
  the event loop is not blocked.
- RichCLIRenderer.pause_live()/resume_live(): the CLI prompt callback pauses
  the transient Live display before prompting (so the prompt renders cleanly)
  and resumes after.
- code_app _initialize_session wires the callback (pause Live -> adapter
  prompt_and_persist -> resume Live). CLI-only; gated by
  DANA_CODE_PERMISSION_PREFLIGHT_ENABLED.

Tests: hook callback allow/deny (test_d76); prompt_and_persist allow-once
(no grant) + allow-always (durable grant) (test_d73). 2730 passed (1 known
flake test_reap_child_pids); ruff clean; two-question tmux smoke green
(real tool list + s[::-1]; /exit CLEAN) — no P0 regression.
D7 follow-up 1+2: the model calls each MCP tool BY NAME (with its real
inputSchema) instead of via the single-dispatch call_mcp_tool wrapper, and
each MCP tool is classified individually for permission policy.

Approach A2 (Decision 2 holds — NO D2 ToolExecutionEngine reroute; the STAR
loop stays canonical; changes are additive):
- dispatch_wrapper: build_mcp_dispatch_resource now also exposes per-MCP
  ToolCatalogEntry schemas (mcp_tool_to_catalog_entry — correct inputSchema,
  namespaced server:tool), the mcp_names set, and a dispatch_map
  (tool_name -> async callable -> formatted result). _format_mcp_result +
  _make_mcp_dispatcher extracted as module helpers.
- native_catalog: build_native_tool_catalog accepts mcp_names; registered
  namespaced MCP tools classify as EXECUTE/non-sensitive (flow to
  mode/grant/prompt); unknown names stay fail-cautious (is_sensitive=True).
- tool_executor: ToolExecutor gains an mcp_dispatch_getter (+ setter);
  _dispatch_single_call_async checks the MCP dispatch map BEFORE the
  @named_tool registry. Namespaced server:tool names do not collide with
  native names, so native dispatch is unchanged.
- agent_session._build_tool_catalog: ensures _native_tools is built, appends
  the per-MCP schemas (idempotent — the cached _native_tools is not rebuilt,
  so they persist across turns), passes mcp_names to the catalog, and wires
  the mcp_dispatch_getter on the agent's ToolExecutor.

The single-dispatch call_mcp_tool wrapper (b5f443d) is kept as a fallback
(AC #4 stays green via both paths); per-tool is preferred (real schemas).

Verification: 2736 tests pass (+6 new; 1 known flake test_reap_child_pids);
ruff clean; two-question tmux smoke (no MCP config) green — Q1 real tool
list, Q2 s[::-1], /exit CLEAN (no P0 regression; with no MCP config, no MCP
tools are injected -> turn path unchanged). Per-tool dispatch end-to-end
covered by unit tests (ToolExecutor dispatches fs:greet by name; AgentSession
surfaces the schema + dispatches by name -> 'hello world').
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant