diff --git a/.github/workflows/test-jvagent.yaml b/.github/workflows/test-jvagent.yaml index 2e6094b8..1e6e2a23 100644 --- a/.github/workflows/test-jvagent.yaml +++ b/.github/workflows/test-jvagent.yaml @@ -59,6 +59,54 @@ jobs: pip install pre-commit pre-commit run --all-files + harness-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.11' + - run: python -m pip install --upgrade pip + - run: python -m pip install -e '.[test]' + - name: Conformance lane + run: pytest tests/conformance tests/harness -m harness_conformance -q --tb=short + + harness-two-worker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.11' + - run: python -m pip install --upgrade pip + - run: python -m pip install -e '.[test]' + - name: Two-worker / crash-recovery lane + run: pytest tests/conformance/test_leases.py tests/conformance/test_invocation_recovery.py -q --tb=short + + harness-isolation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.11' + - run: python -m pip install --upgrade pip + - run: python -m pip install -e '.[test]' + - name: Skill isolation lane + run: pytest tests/ -m harness_isolation -q --tb=short + + harness-load: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.11' + - run: python -m pip install --upgrade pip + - run: python -m pip install -e '.[test]' + - name: Load lane + run: pytest tests/ -m harness_load -q --tb=short + jvchat: runs-on: ubuntu-latest # A registry stall in `npm ci` once held this job for the 45-minute default diff --git a/.planning/GLOSSARY.md b/.planning/GLOSSARY.md index ec280143..b174e9a5 100644 --- a/.planning/GLOSSARY.md +++ b/.planning/GLOSSARY.md @@ -70,6 +70,18 @@ Former Rails-pattern router (weight `-200`). Removed in favor of `OrchestratorIn ### `InteractWalker` jvspatial `Walker` subclass that drives the interact subsystem. Source: `jvagent/action/interact/interact_walker.py:47+`. Bootstraps `User` / `Conversation` / `Interaction` and visits each top-level `InteractAction` in `weight` order. +### `NativeCaller` +Admission identity `(agent_id, user_id, session_id)`. Host scopes map to `session_id` outside jvagent. Source: [`jvagent/harness/contracts.py`](../jvagent/harness/contracts.py). See ADR-0054. + +### `HarnessRuntime` +Store-backed harness runtime: snapshots, TurnRun journal, invocation ledger, outbox, session leases, traces, skill staging. Share a `HarnessStore` for two-worker tests. Source: [`jvagent/harness/runtime.py`](../jvagent/harness/runtime.py). + +### `ToolSurfaceSnapshot` +Immutable per-turn tool/skill surface keyed by `snapshot_id` + NativeCaller. Revoked/expired snapshots cannot dispatch. Source: [`jvagent/harness/contracts.py`](../jvagent/harness/contracts.py). + +### `TurnRun` +Log-shaped execution journal (not a conversation Node). States: accepted → running → waiting_tool | waiting_approval → terminal / recovery_required. Source: [`jvagent/harness/runtime.py`](../jvagent/harness/runtime.py). + ### `LanguageModelAction` Subclass of `BaseModelAction` for LLM providers. Source: `jvagent/action/model/language/base.py:345`. Concrete subclasses: Anthropic, OpenAI, OpenRouter, Ollama. diff --git a/.planning/MILESTONES.md b/.planning/MILESTONES.md new file mode 100644 index 00000000..70534c33 --- /dev/null +++ b/.planning/MILESTONES.md @@ -0,0 +1,16 @@ +# Milestones + +## v1 Orchestrator (shipped) + +Orchestrator as the single executive, unified tool surface, lean surfacing, identity/egress, two skill specs, CUCS. Historical plan: [`archive/EXECUTIVE-ROADMAP.md`](archive/EXECUTIVE-ROADMAP.md). + +Phases: pre-GSD (not numbered in this file). + +## v2.0 Harness Excellence (in progress) + +**Started:** 2026-09-17 +**Goal:** Host-neutral reliability and extensibility under many agents, users, and sessions. +**Phases:** 1–5 +**Source:** [`docs/HARNESS_EXCELLENCE_PLAN.md`](../docs/HARNESS_EXCELLENCE_PLAN.md) + +Not started until Phase 1 HP-00 contracts freeze. diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 3b5a4f8f..55c1a9d8 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -1,7 +1,8 @@ # jvagent — Project Vision -> **Status**: Draft, AI-agent-maintained. Last review: 2026-05-17. +> **Status**: Draft, AI-agent-maintained. Last review: 2026-09-17. > **Companion docs**: [`SPEC.md`](SPEC.md) for normative semantics, [`architecture.md`](architecture.md) for diagrams, [`../README.md`](../README.md) for user-facing onboarding. +> **Current milestone**: [`ROADMAP.md`](ROADMAP.md) — v2.0 Harness Excellence. ## TL;DR @@ -24,6 +25,24 @@ The model is the pilot. Tools are the controls. Skills are the flight plan. ([so --- +## Current Milestone: v2.0 Harness Excellence + +**Goal:** Make jvagent the dependable, graph-native harness for many agents, users, and simultaneous sessions — without sacrificing model agency, Claude-skill compatibility, or host-neutrality. + +**North star:** [`docs/HARNESS_EXCELLENCE_PLAN.md`](../docs/HARNESS_EXCELLENCE_PLAN.md) + +**Target features:** +- Native identity `(agent_id, user_id, session_id)` as the only isolation key in core +- Immutable `ToolSurfaceSnapshot` for tools and skills; no cross-session cache leakage +- Graph-backed `TurnRun` journal, invocation ledger, and durable event outbox +- Host-neutral `HostCapabilityProvider` with embedded and remote adapters +- Signed skill manifests and selectable isolation backends +- Correlated trace/replay, load evidence, and a release compatibility matrix + +**Requirements:** [`REQUIREMENTS.md`](REQUIREMENTS.md) · **Roadmap:** [`ROADMAP.md`](ROADMAP.md) · **State:** [`STATE.md`](STATE.md) + +--- + ## Target workloads ### 1. Turn-based conversational agents @@ -106,7 +125,39 @@ This repo is `jvagent` only. The graph framework is at `../jvspatial` (sibling d ## Roadmap -In-flight planning lives at [`EXECUTIVE-ROADMAP.md`](archive/EXECUTIVE-ROADMAP.md) (archived now that v1 has shipped). When this project adopts the GSD workflow, roadmaps move to a `ROADMAP.md` at the `.planning/` root. +- **v1 Orchestrator** — shipped. Historical plan: [`archive/EXECUTIVE-ROADMAP.md`](archive/EXECUTIVE-ROADMAP.md). +- **v2.0 Harness Excellence** — active. [`ROADMAP.md`](ROADMAP.md), sourced from [`docs/HARNESS_EXCELLENCE_PLAN.md`](../docs/HARNESS_EXCELLENCE_PLAN.md). + +## Key Decisions + +| Decision | Rationale | Outcome | +|----------|-----------|---------| +| Native identity only — no workspace/org/App in core | Hosts map their scopes to session ids; jvagent stays reusable | — Pending v2.0 | +| Snapshots, never live host imports into the Orchestrator | Prevents cache leakage and host-domain coupling | — Pending v2.0 | +| Authority bound server-side, never in model payloads | Model-generated JSON cannot escalate capability | — Pending v2.0 | +| Subprocess limits are development-only containment | Not a sandbox for untrusted code | — Pending v2.0 | +| Single-process remains a documented narrower profile | Active-active is optional, not required | — Pending v2.0 | +| HP-08 and HP-09 run in parallel after HP-03 + HP-05 | Skill hardening does not wait on host transport | — Pending v2.0 | + +## Evolution + +This document evolves at phase transitions and milestone boundaries. + +**After each phase transition:** +1. Requirements invalidated? → Move to Out of Scope with reason +2. Requirements validated? → Move to Validated with phase reference +3. New requirements emerged? → Add to Active +4. Decisions to log? → Add to Key Decisions +5. "What This Is" still accurate? → Update if drifted + +**After each milestone:** +1. Full review of all sections +2. Core Value check — still the right priority? +3. Audit Out of Scope — reasons still valid? +4. Update Context with current state + +--- +*Last updated: 2026-09-17 after starting milestone v2.0 Harness Excellence* --- diff --git a/.planning/README.md b/.planning/README.md index 0fc9e98c..4beac0b5 100644 --- a/.planning/README.md +++ b/.planning/README.md @@ -10,7 +10,13 @@ records it links into. User-facing onboarding lives in the root ``` .planning/ - PROJECT.md big-picture overview + PROJECT.md big-picture overview + current milestone + REQUIREMENTS.md v2.0 Harness Excellence requirements (REQ-IDs) + ROADMAP.md GSD phases 1–5 (HP-00 … HP-12) + STATE.md living execution position + MILESTONES.md v1 shipped / v2.0 in progress + config.json GSD workflow config + phases/ one PLAN.md per HP SPEC.md normative semantics (invariants, contracts) PATTERNS.md deployment patterns (Rails vs. Orchestrator) architecture.md diagrams (boot, interact, executive, pruning) @@ -19,7 +25,7 @@ records it links into. User-facing onboarding lives in the root runbooks/ step-by-step operator/dev procedures adr/ architecture decision records (immutable once accepted) specs/ design specs for feature work (agent-authored) - plans/ task-by-task implementation plans (agent-authored) + plans/ historical task-by-task plans (pre-GSD) archive/ superseded / shipped-and-historical docs ``` @@ -34,6 +40,7 @@ by slug (e.g. `specs/-foo-design.md` ↔ `plans/-foo.md`). | You want to… | Read | |---|---| | Get the big picture | [`PROJECT.md`](PROJECT.md) | +| Execute v2.0 Harness Excellence | [`ROADMAP.md`](ROADMAP.md) · [`REQUIREMENTS.md`](REQUIREMENTS.md) · [`../docs/HARNESS_EXCELLENCE_PLAN.md`](../docs/HARNESS_EXCELLENCE_PLAN.md) | | Look up normative semantics | [`SPEC.md`](SPEC.md) | | Choose a deployment pattern | [`PATTERNS.md`](PATTERNS.md) | | See diagrams | [`architecture.md`](architecture.md) | @@ -85,6 +92,7 @@ those records covered patterns (bridge/helm/cockpit) that were removed. | [0018](adr/0018-lean-tool-surfacing.md) | Lean tool surfacing (threshold-auto progressive tool disclosure) | Accepted | | [0026](adr/0026-task-driven-turn-lock.md) | Task-driven turn-lock (work-stack orchestration) | Accepted | | [0027](adr/0027-conversation-use-case-spec.md) | Conversation Use Case Specification (CUCS) | Accepted | +| [0054](adr/0054-harness-contracts.md) | Host-neutral harness contracts (NativeCaller, TurnRun, snapshot, provider) | Accepted | ## specs/ — design specs diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 00000000..a47a7be4 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,94 @@ +# Requirements: jvagent v2.0 Harness Excellence + +**Defined:** 2026-09-17 +**Core Value:** A dependable, graph-native harness for many agents, users, and sessions — model as pilot, tools as controls, skills as flight plan. +**Source:** [`docs/HARNESS_EXCELLENCE_PLAN.md`](../docs/HARNESS_EXCELLENCE_PLAN.md) +**Conformance:** HC-01 … HC-12 in that plan map 1:1 onto the IDs below. + +## v2.0 Requirements + +### Contracts and baseline + +- [ ] **CTRT-01**: A native, embedded, or remote integrator can run the same harness conformance suite against frozen TurnRun, snapshot, invocation, event, and provider fixtures; rejected transitions are explicit; no host-domain field appears in public jvagent models or APIs (HC-01 contract half) +- [ ] **BASE-01**: An operator can name every process-local component, its scope, its replacement decision, and the regression test that proves restart or multi-worker loss + +### Identity and snapshots + +- [ ] **IDNT-01**: Concurrent callers on distinct agents, users, and sessions stay isolated using only `(agent_id, user_id, session_id)` — no host-specific fields in jvagent core (HC-01) +- [ ] **IDNT-02**: Concurrent session admission across workers produces one User and one Conversation for the same identity; same-session ownership policy is explicit and tested +- [ ] **SNAP-01**: A tool or skill snapshot cannot leak across sessions or be reused after expiry or revocation (HC-02) +- [ ] **SNAP-02**: Dynamic tool and skill changes take effect on the next snapshot without contaminating any other in-flight caller + +### Durable execution + +- [ ] **RUN-01**: A crash before, during, or after tool dispatch has an explicit recovery result and never silently duplicates a supported side effect (HC-03) +- [ ] **RUN-02**: Model outage, retry, fallback, budget exhaustion, cancellation, and tool timeout leave an inspectable terminal run state (HC-09) +- [ ] **INV-01**: Retries reuse the same `invocation_id`; mutating native tools declare idempotency class; non-retryable tools produce a typed recovery state +- [ ] **DELV-01**: SSE and channel delivery replay events in order using cursors and render each final response once (HC-04) + +### Distributed runtime and extensibility + +- [ ] **DIST-01**: Two workers can serve different sessions concurrently and coordinate same-session ownership correctly (HC-05) +- [ ] **DIST-02**: Worker loss preserves queued delivery and either resumes or safely marks active runs for recovery (HC-06) +- [ ] **HOST-01**: Native, embedded-host, and remote-host tool providers pass the same invocation and revocation contract suite (HC-08) +- [ ] **HOST-02**: A sample host can supply per-session dynamic tools and skills; revocation takes effect at the next snapshot; jvagent remains unaware of the host's data model +- [ ] **SKIL-01**: JV and Claude skill bundles materialize from verified manifests into isolated caller slices (HC-07) +- [ ] **SKIL-02**: Skill activation is reproducible from its digest; a revoked or changed skill cannot run under a stale snapshot; untrusted script skills are refused without an approved isolation backend + +### Operational excellence + +- [ ] **OBSV-01**: An operator can explain any completed or failed turn from one correlation id without accessing another user's data (HC-10) +- [ ] **PERF-01**: Load tests preserve p95 targets and event ordering under many users and sessions; no optimization weakens ordering, identity isolation, or egress (HC-11) +- [ ] **REL-01**: A release record identifies artifact digest, contract versions, supported topology, evidence, limitations, and rollback path (HC-12) + +## Future (not this milestone) + +- Which durable transport is first for event outbox and distributed coordination +- Separate checkpoint vs event retention policies per backend +- Whether background work uses the same TurnRun executor or a sibling durable worker +- External skill publisher registry and revocation service (after signed manifests and isolation backends prove out) + +## Out of Scope + +| Feature | Reason | +|---------|--------| +| Host concepts (workspaces, organizations, Apps, domain schemas) in jvagent core | Hosts map scopes to session ids; jvagent stays host-neutral | +| Semantic router / workflow designer / business-rule engine in the Orchestrator | Thin harness: judgment stays in skills and the model | +| A second memory database competing with jvspatial graph state | Graph remains the runtime state substrate | +| Exactly-once for third-party side effects with no idempotency mechanism | Harness supplies invocation identity; domain tools own exactly-once | +| Treating subprocess resource limits as a sandbox for untrusted code | Dev-only containment; untrusted scripts need an approved isolation backend | +| Requiring active-active for every deployment | Single-process remains supported with explicitly narrower guarantees | +| Embedding Integral or any other product's model in tests | HP-08 uses a small independent host fixture; `examples/jvagent_app` is the native reference | + +## Traceability + +| Requirement | Phase | HP | Status | +|-------------|-------|----|--------| +| CTRT-01 | Phase 1 | HP-00 | Pending | +| BASE-01 | Phase 1 | HP-01 | Pending | +| IDNT-01 | Phase 2 | HP-02 | Pending | +| IDNT-02 | Phase 2 | HP-02 | Pending | +| SNAP-01 | Phase 2 | HP-03 | Pending | +| SNAP-02 | Phase 2 | HP-03 | Pending | +| RUN-01 | Phase 3 | HP-04 | Pending | +| RUN-02 | Phase 3 | HP-04 | Pending | +| INV-01 | Phase 3 | HP-05 | Pending | +| DELV-01 | Phase 3 | HP-06 | Pending | +| DIST-01 | Phase 4 | HP-07 | Pending | +| DIST-02 | Phase 4 | HP-07 | Pending | +| HOST-01 | Phase 4 | HP-08 | Pending | +| HOST-02 | Phase 4 | HP-08 | Pending | +| SKIL-01 | Phase 4 | HP-09 | Pending | +| SKIL-02 | Phase 4 | HP-09 | Pending | +| OBSV-01 | Phase 5 | HP-10 | Pending | +| PERF-01 | Phase 5 | HP-11 | Pending | +| REL-01 | Phase 5 | HP-12 | Pending | + +**Coverage:** +- v2.0 requirements: 19 total +- Mapped to phases: 19 +- Unmapped: 0 ✓ + +--- +*Requirements defined: 2026-09-17* +*Last updated: 2026-09-17 after milestone v2.0 roadmap* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 00000000..35c0a760 --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,129 @@ +# Roadmap: jvagent + +## Milestones + +- ✅ **v1 Orchestrator** — shipped (see [`.planning/archive/EXECUTIVE-ROADMAP.md`](archive/EXECUTIVE-ROADMAP.md)) +- 🚧 **v2.0 Harness Excellence** — Phases 1–5 (in progress) +- Source plan: [`docs/HARNESS_EXCELLENCE_PLAN.md`](../docs/HARNESS_EXCELLENCE_PLAN.md) + +## Overview + +Freeze host-neutral contracts and inventory process-local state. Then isolate identity and snapshots. Then persist turns, invocations, and delivery. Then add optional active-active plus host/skill hardening. Then prove it with traces, load, and a release record. + +**Do not** claim crash-safe or active-active behavior until Phase 3 (HP-06) and Phase 4 (HP-07) evidence exists. + +**Parallelism after Phase 1 freeze:** HP-02, HP-03, and HP-04 may start together. HP-08 and HP-09 run in parallel after HP-03 + HP-05 (HP-09 does **not** wait on HP-08). + +## Phases + +**Phase Numbering:** first GSD milestone; numbering starts at 1. + +- [x] **Phase 1: Contracts and baseline** — Freeze harness contracts and inventory process-local state +- [x] **Phase 2: Identity and snapshots** — Store-backed admission and snapshot-scoped caches +- [x] **Phase 3: Durable execution** — TurnRun, invocation ledger, durable outbox +- [x] **Phase 4: Distributed runtime and extensibility** — Leases, host provider, skill hardening +- [x] **Phase 5: Operational excellence and release proof** — Trace/replay, capacity, release record + +## Phase Details + +### Phase 1: Contracts and baseline + +**Goal**: Freeze the host-neutral identity, snapshot, TurnRun, invocation, event, and provider contracts, and attach a concrete inventory of process-local state. +**Depends on**: Nothing (first phase) +**Requirements**: CTRT-01, BASE-01 +**Success Criteria** (what must be TRUE): + 1. Contract fixtures define valid and rejected transitions for TurnRun, snapshot, invocation, event, and provider APIs + 2. No host-domain field (`workspace_id`, org, App, domain schema) appears in public jvagent models or APIs + 3. Every process-local cache, lock, bus, breaker, and background registry has an owner, scope, replacement decision, and regression target +**Plans**: 2 plans + +Plans: +- [x] 01-01: HP-00 Harness contract ADRs and conformance suite +- [x] 01-02: HP-01 Baseline reliability audit + +### Phase 2: Identity and snapshots + +**Goal**: Make `(agent_id, user_id, session_id)` the admission key and serve tools/skills only through immutable snapshots. +**Depends on**: Phase 1 +**Requirements**: IDNT-01, IDNT-02, SNAP-01, SNAP-02 +**Success Criteria** (what must be TRUE): + 1. Concurrent creates across workers produce one User and one Conversation + 2. Simultaneous turns on distinct sessions remain isolated; same-session policy is explicit and tested + 3. Two concurrent users/sessions receive only their own snapshots + 4. Dynamic tool/skill changes affect a new snapshot without contaminating any other caller +**Plans**: 2 plans + +Plans: +- [x] 02-01: HP-02 Native identity and session admission +- [x] 02-02: HP-03 ToolSurfaceSnapshot and cache discipline + +### Phase 3: Durable execution + +**Goal**: Persist turn lifecycle, tool invocations, and outbound events so crashes and reconnects have an explicit story. +**Depends on**: Phase 2 (HP-04 may start against frozen Phase 1 fixtures in parallel with HP-02) +**Requirements**: RUN-01, RUN-02, INV-01, DELV-01 +**Success Criteria** (what must be TRUE): + 1. A crash after tool dispatch is diagnosable; completed read tools are not repeated unnecessarily; unsafe writes require a visible recovery decision + 2. Retries reuse `invocation_id`; a duplicate dispatch cannot duplicate a supported mutating effect + 3. Reconnecting clients replay missed frames in order without duplicate rendered messages + 4. A reply created on one worker can be delivered by another (outbox, not process-local bus) +**Plans**: 3 plans + +Plans: +- [x] 03-01: HP-04 TurnRun journal and resumable execution +- [x] 03-02: HP-05 Invocation ledger and idempotency adapters +- [x] 03-03: HP-06 Durable event outbox and resumable streaming + +### Phase 4: Distributed runtime and extensibility + +**Goal**: Optional active-active coordination, a host-neutral capability provider, and signed/isolated skill materialization. +**Depends on**: Phase 3 +**Requirements**: DIST-01, DIST-02, HOST-01, HOST-02, SKIL-01, SKIL-02 +**Success Criteria** (what must be TRUE): + 1. A two-worker test handles concurrent users, session contention, worker loss, and proactive delivery without lost or cross-delivered events + 2. A sample host supplies per-session dynamic tools and skills; revocation takes effect at the next snapshot; jvagent never sees the host data model + 3. Native, embedded, and remote providers pass the same invocation/revocation suite + 4. Skill activation is reproducible from digest; stale or revoked skills cannot run; untrusted scripts refuse without an approved isolation backend +**Plans**: 3 plans + +Plans: +- [x] 04-01: HP-07 Active-active coordination +- [x] 04-02: HP-08 HostCapabilityProvider reference implementation +- [x] 04-03: HP-09 Skill package and execution hardening + +HP-08 and HP-09 may execute in parallel. Both require HP-03 + HP-05, not each other. + +### Phase 5: Operational excellence and release proof + +**Goal**: An operator can explain any turn, capacity is measured, and a release artifact carries its guarantees. +**Depends on**: Phase 4 (HP-10 may start after Phase 3; HP-11 needs HP-03 + HP-06 + HP-07) +**Requirements**: OBSV-01, PERF-01, REL-01 +**Success Criteria** (what must be TRUE): + 1. One correlation id reconstructs a completed or failed turn with redaction and no other user's content + 2. Representative many-user/many-session load preserves p95 targets and event ordering + 3. A release record lists artifact digest, contract versions, supported topology, evidence, limitations, and rollback path + 4. Unsupported storage/execution combinations are marked explicitly +**Plans**: 3 plans + +Plans: +- [x] 05-01: HP-10 Trace, replay, and evaluation plane +- [x] 05-02: HP-11 Performance and capacity work +- [x] 05-03: HP-12 Release and compatibility evidence + +## Progress + +**Execution Order:** +Phases execute in numeric order. Inside a phase, plans may run in parallel when the HP dependency map allows. + +| Phase | Milestone | Plans Complete | Status | Completed | +|-------|-----------|----------------|--------|-----------| +| 1. Contracts and baseline | v2.0 | 2/2 | Complete | 2026-09-17 | +| 2. Identity and snapshots | v2.0 | 2/2 | Complete | 2026-09-18 | +| 3. Durable execution | v2.0 | 3/3 | Complete | 2026-09-18 | +| 4. Distributed runtime and extensibility | v2.0 | 3/3 | Complete | 2026-09-18 | +| 5. Operational excellence and release proof | v2.0 | 3/3 | Complete | 2026-09-18 | + +**Coverage:** 19/19 requirements mapped. Unmapped: 0. + +--- +*Roadmap created: 2026-09-17 for milestone v2.0 Harness Excellence* diff --git a/.planning/SPEC.md b/.planning/SPEC.md index 0f68a52f..cc4fcb2d 100644 --- a/.planning/SPEC.md +++ b/.planning/SPEC.md @@ -124,6 +124,21 @@ Rationale and consequences: [`adr/0012-skill-executive-architecture.md`](adr/001 Harness design contract (thin server, thick SOP): [`docs/thin-harness.md`](../docs/thin-harness.md). Interview profile: [`jvagent/action/interview/docs/thin-harness.md`](../jvagent/action/interview/docs/thin-harness.md). +### 3.4 Harness contracts (ADR-0054) + +Types and validators live in [`jvagent/harness/contracts.py`](../jvagent/harness/contracts.py) (`CONTRACT_VERSION`). Runtime (journals, ledger, outbox, leases, snapshots, traces) is [`jvagent/harness/runtime.py`](../jvagent/harness/runtime.py). TurnRun is a log-shaped journal (I-GRAPH-02), not a conversation Node. + +- `NativeCaller` ([`contracts.py:131`](../jvagent/harness/contracts.py)) is `(agent_id, user_id, session_id)`. `native_caller_from_mapping` ([`contracts.py:149`](../jvagent/harness/contracts.py)) rejects host-domain keys (`workspace_id`, `organization`, `organization_id`, `org_id`, `content_profile_id`). Public interact/embed `data` payloads are rejected the same way. +- `TurnRunState` + `assert_turn_run_transition` ([`contracts.py:42`](../jvagent/harness/contracts.py), [`contracts.py:122`](../jvagent/harness/contracts.py)) define legal lifecycle edges. `HarnessRuntime.start_turn` / `transition` persist them. +- `ToolSurfaceSnapshot` ([`contracts.py:167`](../jvagent/harness/contracts.py)) is immutable; `cache_key()` includes `snapshot_id` and NativeCaller; revoked/expired snapshots raise `HarnessContractError`. Catalog cache keys match ([`catalog.py`](../jvagent/action/orchestrator/catalog.py)). +- `InvocationRecord` ([`contracts.py:193`](../jvagent/harness/contracts.py)) and `IdempotencyClass` ([`contracts.py:53`](../jvagent/harness/contracts.py)) describe dispatch identity. Ledger: `HarnessRuntime.begin_invocation` / `finish_invocation`; `@tool(idempotency_class=...)`. +- `EventEnvelope` ([`contracts.py:204`](../jvagent/harness/contracts.py)) is the durable-delivery shape (`sequence >= 1`). ResponseBus appends to the outbox before fan-out; SSE may replay via `cursor`. +- `HostCapabilityProvider` ([`contracts.py:239`](../jvagent/harness/contracts.py)) is an async Protocol. Adapters: [`provider.py`](../jvagent/harness/provider.py) (`native` / `embedded` / `remote`). Model payloads must not carry authority keys (`reject_model_authority_fields`, [`contracts.py:113`](../jvagent/harness/contracts.py)). +- Same-session policy is **lease** (`SessionBusy` if another worker holds it). JSON/SQLite active-active is **unsupported** ([`docs/HARNESS_DEPLOYMENT.md`](../docs/HARNESS_DEPLOYMENT.md)). +- Conformance suite: `tests/conformance/` (pytest marker `harness_conformance`). Native reference app: `examples/jvagent_app`. Independent host fixture: `tests/conformance/fixtures/fake_host/` (not a product host). + +See [ADR-0054](adr/0054-harness-contracts.md). + --- ## 4. Action contract @@ -188,7 +203,7 @@ Errors raised by these hooks are logged automatically by the action's `enable()` ### 4.4 Tools and capabilities -- `get_tools() -> List[Tool]` ([`base.py:259`](../jvagent/action/base.py)) — every `Action` MAY expose tools to the agentic loop (e.g. the Orchestrator's think-act-observe loop). Each tool wraps a callable with a JSON Schema for arguments; they are registered with an `action__` prefix in the tool registry. `InteractAction.get_tools()` forwards to `execute(visitor)` and builds the tool description from the manifest (`purpose` + `activates_on`, via `routing_triggers()`). +- `get_tools() -> List[Tool]` ([`base.py:259`](../jvagent/action/base.py)) — every `Action` MAY expose tools to the agentic loop (e.g. the Orchestrator's think-act-observe loop). Each tool wraps a callable with a JSON Schema for arguments; they are registered with an `action__` prefix in the tool registry. `InteractAction.get_tools()` forwards to `execute(visitor)` and builds the tool description from the manifest (`purpose` + `activates_on`, via `routing_triggers()`). Target admission surface is a `ToolSurfaceSnapshot` ([`contracts.py:163`](../jvagent/harness/contracts.py)); today's cache is still per-agent ([`catalog.py:45`](../jvagent/action/orchestrator/catalog.py)) until HP-03. - `get_capabilities() -> List[str]` ([`base.py:180`](../jvagent/action/base.py)) — short capability strings aggregated by `ReplyAction` for reply-prompt injection. ### 4.5 Action discovery @@ -219,7 +234,8 @@ See [`adr/0004-namespace-isolation.md`](adr/0004-namespace-isolation.md) and [`a ### 5.1 Identity - `User.memory_id` + `User.user_id` together form a compound unique key per `Memory` subgraph (compound index at `memory/user.py:16-24`). -- A `lock_manager` (`memory/lock_manager.py`) acquires a per-`(memory_id, user_id)` lock before `_get_user_unlocked()` to prevent duplicate `User` rows under concurrent creates. +- Harness admission identity is `NativeCaller(agent_id, user_id, session_id)` ([`contracts.py:131`](../jvagent/harness/contracts.py)). `Conversation.session_id` remains globally unique ([`conversation.py`](../jvagent/memory/conversation.py)). `get_user` / `get_session` wrap `distributed_lease` plus the in-process lock; two-worker identity upsert is proven on a shared `HarnessStore` ([`runtime.py`](../jvagent/harness/runtime.py)). Redis/Dynamo still required for cluster-wide User/Conversation uniqueness on JSON. +- A `lock_manager` (`memory/lock_manager.py`) acquires a per-`(memory_id, user_id)` lock before `_get_user_unlocked()` to prevent duplicate `User` rows under concurrent creates. The lock is process-local today. ### 5.2 Conversation chaining @@ -290,7 +306,7 @@ See [`adr/0005-app-yaml-agent-yaml-split.md`](adr/0005-app-yaml-agent-yaml-split ## 7. Response bus -The response bus ([`jvagent/action/response/response_bus.py`](../jvagent/action/response/response_bus.py)) is **per-agent**. Each `Agent` lazily constructs one via `Agent.get_response_bus()` ([`agent.py:256`](../jvagent/core/agent.py)). +The response bus ([`jvagent/action/response/response_bus.py`](../jvagent/action/response/response_bus.py)) is **per-agent**. Each `Agent` lazily constructs one via `Agent.get_response_bus()` ([`agent.py:256`](../jvagent/core/agent.py)). Session queues and subscribers are process-local (`_agent_bus_registry`, `_session_queues`). Target durable shape is `EventEnvelope` ([`contracts.py:200`](../jvagent/harness/contracts.py)); outbox persistence is HP-06. - Channel adapters (`EmailAction`, `WhatsAppAction`, `FacebookAction`, etc.) register with the bus and translate messages to channel-specific transports. - Filters can drop, transform, or duplicate messages per channel. @@ -363,6 +379,8 @@ There is **no external task queue** (no Celery / RQ). Long-lived autonomous work 10. Flow continuation is configurable via `lock_active_flow` ([ADR-0013](adr/0013-togglable-deterministic-turn-lock.md)). When on (default), the active flow's IA tool is dispatched with no model round-trip; when off, the flow is surfaced as routable context and the model decides. See §3.3 invariants 2–3. 11. Routing is tool selection. There is no separate router or capability registry; IAs (as tools), persona, core services, and skills are all tools. A flow's control-task (turn-lock) is persisted on the conversation `TaskStore`; the active flow is surfaced as a routable tool and continued by model tool selection next turn. See §3.3 invariant 4. 12. Access control gates tool dispatch (`tool:*`), including IA-as-tool execution (`tool:delegate:{name}`); a denial routes to the orchestrator's safe-fallback. See §3.3 invariant 6. +13. Public harness types use `NativeCaller` only — no host-domain fields in `jvagent.harness` ([ADR-0054](adr/0054-harness-contracts.md), [`contracts.py:13`](../jvagent/harness/contracts.py)). +14. Model-generated tool payloads MUST NOT carry authority keys (`reject_model_authority_fields`, [`contracts.py:111`](../jvagent/harness/contracts.py)). --- @@ -385,6 +403,7 @@ Load-bearing design choices are captured as ADRs: - [`adr/0010-executive-centers-architecture.md`](adr/0010-executive-centers-architecture.md) *(superseded by ADR-0012; retained as history)* - [`adr/0011-skills-two-kinds.md`](adr/0011-skills-two-kinds.md) - [`adr/0012-skill-executive-architecture.md`](adr/0012-skill-executive-architecture.md) +- [`adr/0054-harness-contracts.md`](adr/0054-harness-contracts.md) --- diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 00000000..d9ee8c31 --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,72 @@ +# Project State + +## Project Reference + +See: .planning/PROJECT.md (updated 2026-09-17) + +**Core value:** Dependable graph-native harness — model as pilot, tools as controls, skills as flight plan. +**Current focus:** v2.0 Harness Excellence — Phases 1–5 implemented; gap-close in working tree + +## Current Position + +Phase: 5 of 5 (Operational excellence and release proof) +Plan: 3 of 3 in current phase +Status: Implementation complete including gap-close; not committed +Last activity: 2026-09-18 — HP-02 … HP-12 runtime + wires + remaining gaps + +Progress: [██████████] 100% + +## Performance Metrics + +**Velocity:** +- Total plans completed: 13 (uncommitted) +- Average duration: — +- Total execution time: — + +**By Phase:** + +| Phase | Plans | Total | Avg/Plan | +|-------|-------|-------|----------| +| 1 | 2 implemented | 2 | — | +| 2 | 2 implemented | 2 | — | +| 3 | 3 implemented | 3 | — | +| 4 | 3 implemented | 3 | — | +| 5 | 3 implemented | 3 | — | + +## Accumulated Context + +### Decisions + +- Native identity only; no host product concepts in core +- Snapshots, never live host imports into the Orchestrator +- Authority server-side, never in model payloads +- HostCapabilityProvider methods are async +- `turn_cache` ContextVar kept +- Fake host fixture, not Integral +- Same-session policy is **lease** +- TurnRun is a journal Object (I-GRAPH-02), not a conversation Node +- JSON/SQLite active-active is unsupported +- Subprocess ≠ sandbox +- Isolation binary missing → refuse, never subprocess fallback +- Redis/Dynamo lease adapters require an explicit client + +### Pending Todos + +User will commit and open PR. + +### Blockers/Concerns + +- Real gvisor/firecracker/nsjail kernel jails are not implemented in-tree; wrap prefix + PATH check is the contract +- Active-active for Mongo/Postgres stays degraded until a shared lease backend is configured + +## Deferred Items + +None remaining from the v2.0 gap list. Optional later work: Redis/Dynamo *stream* transports (leases already have adapters), remote publisher service (in-process revoke/publish is the registry). + +## Session Continuity + +Last session: 2026-09-18 +Stopped at: All listed HP gaps closed in working tree +Resume file: None + +Next: user commit + PR diff --git a/.planning/adr/0054-harness-contracts.md b/.planning/adr/0054-harness-contracts.md new file mode 100644 index 00000000..65e792fb --- /dev/null +++ b/.planning/adr/0054-harness-contracts.md @@ -0,0 +1,80 @@ +# ADR 0054 — Host-neutral harness contracts + +**Status**: Accepted +**Date**: 2026-09-17 +**Relation**: Extends [ADR-0012](0012-skill-executive-architecture.md) (thin orchestrator), [ADR-0014](0014-identity-on-agent-replyaction-egress.md) (identity/egress), [ADR-0018](0018-lean-tool-surfacing.md) (lean discovery), [ADR-0033](0033-identity-and-locking-substrate.md) (native identity tuples). Does not supersede them. Product roadmap: [`docs/HARNESS_EXCELLENCE_PLAN.md`](../../docs/HARNESS_EXCELLENCE_PLAN.md). + +--- + +## 1. Context + +jvagent already isolates users with `(memory_id, user_id)` and conversations with `session_id`. Hosts (embedded products) still risk leaking domain fields into core, serving process-global tool/skill caches across callers, and treating in-memory buses as durable delivery. + +v2.0 Harness Excellence needs a frozen, host-neutral contract **before** persistence, outbox, or host adapters land. This ADR is that freeze. Runtime wiring is later packages (HP-02 … HP-08). + +## 2. Decision + +### 2.1 NativeCaller + +The only public admission identity is: + +```text +(agent_id, user_id, session_id) +``` + +Implemented as `NativeCaller` in [`jvagent/harness/contracts.py`](../../jvagent/harness/contracts.py). Host scopes (workspaces, organizations, Apps, domain schemas) map to `session_id` **outside** jvagent. Public models and APIs MUST reject `workspace_id`, `organization`, `organization_id`, `org_id`, and `content_profile_id`. + +### 2.2 TurnRun states + +```text +accepted → running → waiting_tool → waiting_approval → running + → completed | failed | cancelled | recovery_required +``` + +Illegal transitions raise `HarnessContractError`. Terminal states do not resume silently. `recovery_required` is explicit; it is not auto-replay. Persistence is HP-04. + +### 2.3 ToolSurfaceSnapshot + +At turn admission the Orchestrator will receive one immutable snapshot (`snapshot_id` + NativeCaller + native/host descriptors + expiry). Cache keys MUST include `snapshot_id`. A revoked or expired snapshot is unusable for new dispatch. In-flight turns keep the admitted snapshot unless the host explicitly revokes it (HP-03). + +Lean discovery (`find_tool`, `load_tool`, `find_skill`, `use_skill`) is unchanged. + +### 2.4 Invocation and events + +Every mutating dispatch will allocate `invocation_id` before the call (HP-05). Idempotency class is one of `idempotent` | `compensatable` | `non_retryable`. Exactly-once for third-party effects without an idempotency mechanism is out of scope. + +Outbound frames will use `EventEnvelope` (`session_id`, monotonic `sequence` ≥ 1, `cursor`, `message_id`, `correlation_id`, `snapshot_id`) persisted before fan-out (HP-06). Delivery is at-least-once; single-egress stays at ReplyAction / EgressGate. + +### 2.5 HostCapabilityProvider + +Optional protocol (async, matching jvagent I/O): + +```text +resolve_snapshot(caller) -> ToolSurfaceSnapshot +invoke(snapshot_id, invocation_id, tool_name, payload) -> ToolResult +load_skill(snapshot_id, skill_key) -> SkillMaterialization +invalidate(selector) -> None +``` + +Authority is bound server-side. Model-generated payloads MUST NOT carry `authority`, `trust_tier`, `capability_token`, `snapshot_secret`, or `isolation_backend`. + +Native, embedded, and remote transports share `tests/conformance/` (HP-08). The sample host is `tests/conformance/fixtures/fake_host/` — not another product's model. + +### 2.6 Guarantee split + +Single-process mode remains supported with narrower guarantees (process-local bus/caches). Active-active and crash-safe claims require HP-06 and HP-07 evidence. Subprocess resource limits are development-only containment, not a sandbox (HP-09). + +### 2.7 Thin harness + +This ADR adds reliability mechanics only. The Orchestrator does not gain semantic routing, intent classification, or host-domain workflows. See [`docs/thin-harness.md`](../../docs/thin-harness.md). + +## 3. Consequences + +- Contract tests run today; runtime isolation/outbox/provider tests are skipped until their HP. +- `register_host_skill_provider` remains until HP-08 replaces it; it is process-global and must not grow host-domain fields. +- ADR-0019 soft plan resume is not a TurnRun journal. + +## 4. Verification + +`tests/harness/test_contracts.py` and `tests/conformance/` (marker `harness_conformance`). +`tests/action/orchestrator/test_no_interview_coupling.py` still passes — harness types do not import interview. diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 00000000..4464854a --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,56 @@ +{ + "mode": "interactive", + "granularity": "standard", + "workflow": { + "research": true, + "plan_check": true, + "verifier": true, + "auto_advance": false, + "nyquist_validation": true, + "security_enforcement": true, + "security_asvs_level": 1, + "security_block_on": "high", + "discuss_mode": "discuss", + "research_before_questions": false, + "code_review_command": null, + "plan_bounce": false, + "plan_bounce_script": null, + "plan_bounce_passes": 2, + "cross_ai_execution": false, + "cross_ai_command": "", + "cross_ai_timeout": 300 + }, + "planning": { + "commit_docs": false, + "search_gitignored": false, + "sub_repos": [] + }, + "parallelization": { + "enabled": true, + "plan_level": true, + "task_level": false, + "skip_checkpoints": true, + "max_concurrent_agents": 3, + "min_plans_for_parallel": 2 + }, + "gates": { + "confirm_project": true, + "confirm_phases": true, + "confirm_roadmap": true, + "confirm_breakdown": true, + "confirm_plan": true, + "execute_next_plan": true, + "issues_review": true, + "confirm_transition": true + }, + "safety": { + "always_confirm_destructive": true, + "always_confirm_external_services": true + }, + "hooks": { + "context_warnings": true + }, + "project_code": "jvagent", + "agent_skills": {}, + "claude_md_path": "./CLAUDE.md" +} diff --git a/.planning/phases/01-contracts-and-baseline/01-01-PLAN.md b/.planning/phases/01-contracts-and-baseline/01-01-PLAN.md new file mode 100644 index 00000000..30a7ceab --- /dev/null +++ b/.planning/phases/01-contracts-and-baseline/01-01-PLAN.md @@ -0,0 +1,92 @@ +# HP-00 — Harness contract ADRs and conformance suite + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Phase:** 1 — Contracts and baseline +**Package:** HP-00 +**Requirements:** CTRT-01 +**Depends on:** nothing +**Goal:** Freeze host-neutral TurnRun, snapshot, invocation, event, and provider contracts with fixtures that native, embedded, and remote integrations all run. + +**Architecture:** Contracts live as ADRs + SPEC + typed fixtures. No product features. Runtime may grow protocol modules (`jvagent/harness/` or similar) only as *types and validators*, not as a live Orchestrator rewrite. + +**Tech stack:** Python 3.12+, pytest, existing ADR/SPEC style. + +**Do not implement:** TurnRun persistence, outbox, host adapters, skill signing. + +--- + +## File structure + +| File | Responsibility | +|---|---| +| `.planning/adr/0054-harness-contracts.md` | Create. NativeCaller, TurnRun, snapshot, invocation, outbox, provider, non-leakage | +| `jvagent/harness/contracts.py` | Create. Dataclasses / Protocol types only | +| `tests/conformance/` | Create. Shared suite + valid/rejected fixtures | +| `.planning/SPEC.md` | Modify. Cite new contracts | +| `docs/ORCHESTRATOR.md` | Modify. Point at snapshot/admission; keep thin-harness language | +| `docs/HARNESS_EXCELLENCE_PLAN.md` | Modify. Mark HP-00 in progress | + +Forbidden in public types: `workspace_id`, `organization`, `App` (host), domain schema names. + +## NativeCaller (locked) + +```text +(agent_id, user_id, session_id) +``` + +Host scopes map to `session_id` outside jvagent. + +## TurnRun states (locked) + +```text +accepted → running → waiting_tool → waiting_approval → running + → completed | failed | cancelled | recovery_required +``` + +Each transition: monotonic seq, timestamp, reason, snapshot_id, correlation_id. + +## Provider protocol (locked) + +```text +resolve_snapshot(agent_id, user_id, session_id) -> ToolSurfaceSnapshot +invoke(snapshot_id, invocation_id, tool_name, payload) -> ToolResult +load_skill(snapshot_id, skill_key) -> SkillMaterialization +invalidate(snapshot_selector) -> acknowledgement +``` + +Authority is not a field on model-generated payloads. + +--- + +### Task 1: ADR-0054 + +- [x] Write ADR covering NativeCaller, TurnRun, ToolSurfaceSnapshot, invocation_id, event cursor, HostCapabilityProvider, cache non-leakage, single-process vs active-active guarantee split +- [x] Explicitly forbid host-domain fields in public models +- [x] Link thin-harness.md; state Orchestrator does not gain semantic routing + +### Task 2: Typed contracts module + +- [x] Add `jvagent/harness/contracts.py` (or equivalent) with NativeCaller, TurnRunState, ToolSurfaceSnapshot, InvocationRecord, EventEnvelope, HostCapabilityProvider Protocol +- [x] Validator rejects extra host-domain keys +- [x] Unit tests for legal vs illegal TurnRun transitions + +### Task 3: Conformance suite skeleton + +- [x] `tests/conformance/test_identity_isolation.py` — HC-01 fixtures (skip/xfail until HP-02) +- [x] `tests/conformance/test_snapshot_revocation.py` — HC-02 +- [x] `tests/conformance/test_invocation_recovery.py` — HC-03 +- [x] `tests/conformance/test_delivery_replay.py` — HC-04 +- [x] `tests/conformance/test_provider_contract.py` — HC-08; parametrize native / embedded / remote +- [x] Marker `harness_conformance`; native fixture uses `examples/jvagent_app` +- [x] Independent fake-host fixture directory under `tests/conformance/fixtures/fake_host/` — **not** Integral + +### Task 4: SPEC + docs + +- [x] SPEC § identity, tools, response bus: cite contract types +- [x] ORCHESTRATOR.md: snapshot at admission; lean discovery remains `find_tool` / `use_skill` +- [x] CHANGELOG Unreleased: contracts freeze (docs/types only) + +**Acceptance:** fixtures define valid and rejected transitions; `pytest tests/conformance -q` collects; no host-domain field in public types; Integrals/workspaces never appear. + +**Verify:** `pytest tests/conformance -q` and `pytest tests/action/orchestrator/test_no_interview_coupling.py -q` diff --git a/.planning/phases/01-contracts-and-baseline/01-02-PLAN.md b/.planning/phases/01-contracts-and-baseline/01-02-PLAN.md new file mode 100644 index 00000000..8111ca0f --- /dev/null +++ b/.planning/phases/01-contracts-and-baseline/01-02-PLAN.md @@ -0,0 +1,53 @@ +# HP-01 — Baseline reliability audit + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Phase:** 1 — Contracts and baseline +**Package:** HP-01 +**Requirements:** BASE-01 +**Depends on:** HP-00 (contract names for the inventory columns) +**Goal:** Inventory every process-local component and record current failure behavior with regression targets. No replacements yet. + +**Architecture:** Audit document + characterization tests. Replacement decisions are recorded, not executed (HP-03/06/07 do the replacements). + +--- + +## Seed inventory (already mapped) + +| Component | Anchor | Likely replacement | +|---|---|---| +| ResponseBus queues/subscribers | `jvagent/action/response/response_bus.py:80,148` | HP-06 durable outbox | +| Tool surface cache (per-agent) | `jvagent/action/orchestrator/catalog.py:45` | HP-03 snapshot cache | +| Skill discovery cache | `jvagent/action/orchestrator/skills.py:18` | HP-03 snapshot cache | +| Host skill providers (process-global) | `jvagent/action/orchestrator/skill_providers.py:21` | HP-08 provider protocol | +| MODEL_BREAKER | `jvagent/action/model/resilience.py:146` | HP-07 shared backend optional | +| In-process conversation locks | `jvagent/memory/lock_manager.py`, `distributed_conversation_lock.py` | HP-02/07 store-backed | +| Embed in-flight tasks | `jvagent/embed/interact.py:18` | HP-04 TurnRun | +| MCP user clients | `jvagent/action/mcp/mcp_action.py:118` | document; out of harness core | +| Webhook wamid dedup | `jvagent/action/utils/meta_webhook_dedup.py:26` | document; channel-local | +| Turn ContextVar cache | `jvagent/action/orchestrator/turn_cache.py:27` | **keep** — already per-task | + +--- + +### Task 1: Written inventory + +- [x] Create `.planning/phases/01-contracts-and-baseline/PROCESS-LOCAL-STATE.md` +- [x] Columns: owner, process-scope, identity key (or none), survives restart?, cross-worker leak?, replacement HP, regression test +- [x] Cover bus, caches, breakers, locks, background registries, sandbox staging, rate limiters + +### Task 2: Characterization tests + +- [x] Restart: in-flight SSE subscriber gone (today) — assert current behavior, mark as HP-06 target +- [x] Duplicate delivery on reconnect with overlapping replay (`tests/action/response/test_streaming_dedup.py` already exists — cite) +- [x] Concurrent session turns — isolate vs collide +- [x] Model error / interrupted tool — current terminal state +- [x] Benchmark fixtures (stubs ok): short chat, tool-rich, streaming, long session, many-user + +### Task 3: Bind to conformance + +- [x] Each inventory row cites a `tests/conformance/` or existing test path +- [x] CHANGELOG: audit artifact only + +**Acceptance:** every process-local component has owner, scope, replacement decision, regression target. + +**Verify:** inventory complete vs grep for module-level `_registry` / `_cache` / `_locks` in `jvagent/` diff --git a/.planning/phases/01-contracts-and-baseline/PROCESS-LOCAL-STATE.md b/.planning/phases/01-contracts-and-baseline/PROCESS-LOCAL-STATE.md new file mode 100644 index 00000000..79eb3b73 --- /dev/null +++ b/.planning/phases/01-contracts-and-baseline/PROCESS-LOCAL-STATE.md @@ -0,0 +1,55 @@ +# Process-local state inventory (HP-01) + +**Date:** 2026-09-17 +**Scope:** jvagent process memory that does not survive restart and is not shared across workers. +**Replacement rule:** record here; do not replace in HP-01. + +Identity column uses ADR-0054 names (`NativeCaller`, `snapshot_id`) when the *target* key is known. Today many rows have no identity key. + +| Component | Owner | Process scope | Identity key today | Survives restart? | Cross-worker leak? | Replacement | Regression | +|---|---|---|---|---|---|---|---| +| `_agent_bus_registry` + session queues/subscribers | ResponseBus | per `agent_id` dict | session_id on queues; not NativeCaller | no | yes — other worker has empty bus | HP-06 outbox | `tests/conformance/test_process_local_baseline.py::test_response_bus_registry_is_process_local`; replay overlap: `tests/action/response/test_streaming_dedup.py` | +| Tool surface cache `_TOOL_SURFACE_CACHE` | orchestrator/catalog | per snapshot+NativeCaller | snapshot_id + caller | no | mitigated by snapshot key (HP-03) | HP-03 done | `test_tool_surface_cache_is_keyed_by_snapshot_and_caller` | +| Skill discovery `_SKILL_DISCOVERY_CACHE` | orchestrator/skills | process dict | path/mtime tuple, not snapshot | no | yes | HP-03 | `test_skill_discovery_cache_is_process_local` | +| Host skill `_providers` | orchestrator/skill_providers | process list | none (agent arg at collect) | no | yes — global overlay | HP-08 | `test_host_skill_providers_are_process_global` | +| `MODEL_BREAKER` / `_states` | model/resilience | process-wide | loop_id | no | yes — breaker not shared | HP-07 optional shared backend | `test_model_breaker_is_process_local` | +| `turn_cache` ContextVar | orchestrator/turn_cache | asyncio task | implicit task | no | no — keep | **keep** | `test_turn_cache_is_contextvar_not_module_dict` | +| Memory lock managers | memory/lock_manager | per loop+key | memory_id+user_id / session | no | yes | HP-02/07 store-backed | `test_memory_locks_are_in_process` | +| Distributed lease `_inproc_locks` | core/distributed_lease | process fallback | lease key | no | yes if used as if distributed | HP-07 | inventory only | +| Embed `_interact_tasks` | embed/interact | process set | none | no | yes | HP-04 TurnRun | inventory only | +| MCP `user_clients` / `tool_cache` | action/mcp | action instance | user / server | no | yes | out of harness core | inventory only | +| Webhook `_seen_wamids` | meta_webhook_dedup | process OrderedDict | wamid | no | yes — duplicate webhooks | channel-local; not HP core | inventory only | +| Rate limiter timestamps | interact/rate_limiter | module singleton | none | no | yes | document | inventory only | +| Agent/action TTL caches | core/cache | process | agent_id | no | stale reads only | keep with TTL; not snapshot | inventory only | +| App `_cached_app` | core/app | process singleton | none | no | N/A single App | keep | inventory only | +| Sandbox / `STAGED_SKILLS_DIR` | core/sandbox, code_execution | filesystem | user path, not snapshot | partial (disk) | path collision if shared FS | HP-09 snapshot staging | inventory only | +| Task monitor `_TICK_ACTIONS_INITIALIZED` | task_monitor | process latch | none | no | duplicate ticks possible | document | inventory only | +| Startup `_startup_completed` | core/startup | process latch | none | no | ok | keep | inventory only | +| Circuit/profile ContextVars | core/profiling | task-local | none | no | no | keep | inventory only | +| Messenger coalescer buffers | facebook_action | process | sender key | no | yes | channel-local | inventory only | +| WhatsApp `_user_locks` / media batch | whatsapp | process | user | no | yes | channel-local | inventory only | + +## Current failure behaviour (characterization) + +| Event | Today | Target HP | +|---|---|---| +| Process restart mid-SSE | subscribers and queues gone; client reconnects to empty bus | HP-06 | +| Overlapping SSE replay | deduped by message id in-process | HP-06 must preserve; see `test_streaming_dedup.py` | +| Concurrent distinct sessions | ContextVar turn cache isolates tasks; tool cache is per-agent so two users of one agent share assembled surface | HP-03 | +| Concurrent same session | in-process conversation lock; not cross-worker | HP-02/07 | +| Model error / interrupted tool | loop terminal via existing guards; no TurnRun journal | HP-04/05 | +| Host skill overlay | every agent in process sees registered providers | HP-08 | + +## Benchmark fixtures (stubs) + +Named in `tests/conformance/test_process_local_baseline.py` and skipped until HP-11: + +- short chat +- tool-rich chat +- streaming +- long session +- many-user concurrency + +## Keep vs replace + +Keep as-is: `turn_cache` ContextVar, App singleton cache, TTL agent/action caches (not tool/skill snapshots). diff --git a/.planning/phases/02-identity-and-snapshots/02-01-PLAN.md b/.planning/phases/02-identity-and-snapshots/02-01-PLAN.md new file mode 100644 index 00000000..19493ff6 --- /dev/null +++ b/.planning/phases/02-identity-and-snapshots/02-01-PLAN.md @@ -0,0 +1,50 @@ +# HP-02 — Native identity and session admission + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Phase:** 2 — Identity and snapshots +**Package:** HP-02 +**Requirements:** IDNT-01, IDNT-02 +**Depends on:** HP-00 +**Goal:** `(agent_id, user_id, session_id)` is the admission identity; concurrent creates across workers yield one User and one Conversation; correlation ids flow end-to-end. + +**Architecture:** Extend ADR-0033 upsert-by-identity (User `(memory_id, user_id)`, Conversation `(session_id)`) with store-backed upsert where the adapter supports it. Admission stays in `Memory.get_session` / `InteractWalker._bootstrap_interaction`. Do not add host scope fields. + +--- + +## Files + +| File | Change | +|---|---| +| `jvagent/memory/manager.py` | Store-backed upsert path for get_user / get_session | +| `jvagent/memory/user.py`, `conversation.py` | Keep compound indexes; no new identity fields | +| `jvagent/action/interact/interact_walker.py` | Correlation id at bootstrap | +| `jvagent/action/interact/session_token.py` | IdentityDecision stays Mode A/B; attach correlation | +| `jvagent/core/distributed_lease.py` | Turn/session ownership lease; document in-process fallback | +| `tests/conformance/test_identity_isolation.py` | Un-xfail HC-01 | +| `tests/memory/` | Concurrent create characterization | + +ADR-0033 remaining: "cross-worker upsert-by-identity for User/Conversation". + +--- + +### Task 1: Formalize NativeCaller at admission + +- [ ] Thread `NativeCaller` from interact HTTP + embed into `get_session` +- [ ] Reject extra host keys at the public interact/embed boundary + +### Task 2: Upsert-by-identity + +- [ ] Concurrent get_or_create User across two tasks → one node +- [ ] Concurrent get_or_create Conversation for same session_id → one node +- [ ] Foreign session still raises (`_resolve_conversation_for_session_or_raise_foreign`) + +### Task 3: Turn ownership + +- [ ] Explicit same-session policy (lease / reject / queue) — pick one, test it +- [ ] Distinct sessions concurrent: no shared mutation +- [ ] Correlation id on Interaction and any background spawn + +**Acceptance:** concurrent creates across workers produce one User and one Conversation; simultaneous distinct sessions isolated; same-session policy explicit. + +**Blocked on:** HP-00 NativeCaller type. **Does not wait on:** HP-03. diff --git a/.planning/phases/02-identity-and-snapshots/02-02-PLAN.md b/.planning/phases/02-identity-and-snapshots/02-02-PLAN.md new file mode 100644 index 00000000..d8569661 --- /dev/null +++ b/.planning/phases/02-identity-and-snapshots/02-02-PLAN.md @@ -0,0 +1,48 @@ +# HP-03 — ToolSurfaceSnapshot and cache discipline + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Phase:** 2 — Identity and snapshots +**Package:** HP-03 +**Requirements:** SNAP-01, SNAP-02 +**Depends on:** HP-00, HP-02 (snapshot key includes NativeCaller) +**Goal:** Orchestrator receives one immutable `ToolSurfaceSnapshot` at admission. Caches key by `snapshot_id`. No process-global tool/skill document is served outside its snapshot. + +**Architecture:** Replace `_TOOL_SURFACE_CACHE` (per-agent) and `_SKILL_DISCOVERY_CACHE` with snapshot-keyed entries. Keep lean discovery (`find_tool`, `load_tool`, `find_skill`, `use_skill`). Keep `turn_cache` ContextVar — it is already per-task. + +Host skills today: `register_host_skill_provider` process-global list. HP-03 may snapshot native+host *descriptors* if a provider is registered, but the generic provider protocol is HP-08. Do not import host services into the Orchestrator. + +--- + +## Files + +| File | Change | +|---|---| +| `jvagent/action/orchestrator/catalog.py` | Snapshot-keyed cache; drop agent-only key as sole key | +| `jvagent/action/orchestrator/skills.py` | Discovery cache includes snapshot_id | +| `jvagent/action/orchestrator/orchestrator_interact_action.py` | Admit snapshot in `_assemble_tools` | +| `jvagent/action/orchestrator/skill_providers.py` | Stop serving unscoped host docs; stub until HP-08 or wrap with snapshot | +| `tests/conformance/test_snapshot_revocation.py` | Un-xfail | + +--- + +### Task 1: Snapshot type at admission + +- [ ] Build `ToolSurfaceSnapshot` once per turn: native tools + skills + expiry + identity +- [ ] Attach `snapshot_id` to model calls, tool wraps, traces + +### Task 2: Cache keys + +- [ ] Every cache key includes `snapshot_id` (and NativeCaller) +- [ ] Invalidate by generation, not `clear()` of process globals per turn +- [ ] Two concurrent users never share a snapshot entry + +### Task 3: Revocation semantics + +- [ ] Later turn may receive a newer snapshot +- [ ] In-flight turn keeps admitted snapshot unless host explicitly revokes +- [ ] Revoked snapshot cannot be reused for a new dispatch + +**Acceptance:** two concurrent users/sessions receive only their own snapshots; dynamic changes do not contaminate other callers. + +**Keep:** ADR-0018 lean surfacing, `tests/action/orchestrator/test_no_interview_coupling.py`. diff --git a/.planning/phases/03-durable-execution/03-01-PLAN.md b/.planning/phases/03-durable-execution/03-01-PLAN.md new file mode 100644 index 00000000..7defd6cc --- /dev/null +++ b/.planning/phases/03-durable-execution/03-01-PLAN.md @@ -0,0 +1,47 @@ +# HP-04 — TurnRun journal and resumable execution + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Phase:** 3 — Durable execution +**Package:** HP-04 +**Requirements:** RUN-01, RUN-02 +**Depends on:** HP-02 (identity); may start against HP-00 fixtures in parallel with HP-02 +**Goal:** Graph-backed `TurnRun` associated with one Interaction. Crashes are diagnosable. Unsafe interruption becomes `recovery_required`, never silent replay. + +**Architecture:** `TurnRun` is execution metadata, not a second conversation model. ADR-0019 `update_plan` remains a *soft* checklist — it does not replace the journal. Persist at tool and safe loop boundaries. Observations: references, not unrestricted chain-of-thought. + +I-GRAPH-01: if `TurnRun` is a Node, wire a structural edge from Interaction (or Conversation) in the same unit of work. If it is log-shaped with no traversal, use `Object` (I-GRAPH-02) — decide in HP-00 ADR and follow it here. + +--- + +## Files + +| File | Change | +|---|---| +| `jvagent/memory/` or `jvagent/harness/turn_run.py` | TurnRun model + journal append | +| `jvagent/action/orchestrator/loop.py` | Persist transitions at tick/tool boundaries | +| `jvagent/action/orchestrator/continuation.py` | Resume recoverable runs; do not rerun completed invocations | +| `jvagent/embed/interact.py` | Replace `_interact_tasks` as source of truth | +| `tests/conformance/test_invocation_recovery.py` | Crash-before / during / after dispatch | + +--- + +### Task 1: TurnRun persistence + +- [ ] States and illegal transitions from HP-00 +- [ ] Fields: seq, timestamp, reason, snapshot_id, correlation_id +- [ ] Edge or Object decision honored + +### Task 2: Checkpoints + +- [ ] Save plan state, phase, admitted snapshot id, safe observation refs +- [ ] Resume after process loss without rerunning completed tool invocations (needs HP-05 ids; stub invocation_id if HP-05 not merged) + +### Task 3: Terminal states + +- [ ] Model outage, retry, fallback, budget, cancel, tool timeout → inspectable terminal state +- [ ] `recovery_required` for unsafe writes + +**Acceptance:** crash after dispatch diagnosable; completed reads not repeated unnecessarily; unsafe writes visible. + +**Do not:** silently replay mutating tools. **Do not:** persist full CoT. diff --git a/.planning/phases/03-durable-execution/03-02-PLAN.md b/.planning/phases/03-durable-execution/03-02-PLAN.md new file mode 100644 index 00000000..3362ef09 --- /dev/null +++ b/.planning/phases/03-durable-execution/03-02-PLAN.md @@ -0,0 +1,46 @@ +# HP-05 — Invocation ledger and idempotency adapters + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Phase:** 3 — Durable execution +**Package:** HP-05 +**Requirements:** INV-01 +**Depends on:** HP-04 +**Goal:** Allocate `invocation_id` before every dispatch. Retries reuse it. Mutating native tools declare idempotency class. Non-retryable tools produce typed recovery state. + +**Architecture:** Ledger record before call. Wrappers for idempotent / compensatable / non-retryable. Preserve native tool calling, parallel sibling dispatch (ADR-0048), action access checks (`wrap_action_tool`). + +Harness does **not** promise exactly-once for third-party effects without an idempotency mechanism. + +--- + +## Files + +| File | Change | +|---|---| +| `jvagent/action/orchestrator/tools.py` | Allocate invocation_id; wrap dispatch | +| `jvagent/tooling/tool_decorator.py` | Optional idempotency class on `@tool` | +| `jvagent/harness/` | InvocationRecord persistence | +| Mutating native tools | Declare class; implement reuse of invocation_id | +| `tests/conformance/test_invocation_recovery.py` | Duplicate dispatch cases | + +--- + +### Task 1: Ledger + +- [ ] Before dispatch: invocation_id, normalized name, input digest, snapshot_id, attempt +- [ ] After: outcome, error class, output ref, causal links to TurnRun / Interaction / events + +### Task 2: Idempotency adapters + +- [ ] Idempotent: retry returns stored result +- [ ] Compensatable: typed compensation path +- [ ] Non-retryable: `recovery_required`, never auto-replay + +### Task 3: Preserve + +- [ ] Parallel sibling tools still legal +- [ ] Access checks still wrap every call +- [ ] Authority not taken from model payload + +**Acceptance:** retries reuse identity; duplicate dispatch cannot duplicate a supported mutating effect. diff --git a/.planning/phases/03-durable-execution/03-03-PLAN.md b/.planning/phases/03-durable-execution/03-03-PLAN.md new file mode 100644 index 00000000..67e1027b --- /dev/null +++ b/.planning/phases/03-durable-execution/03-03-PLAN.md @@ -0,0 +1,47 @@ +# HP-06 — Durable event outbox and resumable streaming + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Phase:** 3 — Durable execution +**Package:** HP-06 +**Requirements:** DELV-01 +**Depends on:** HP-04 +**Goal:** Append outbound frames to a durable per-session stream *before* adapter fan-out. Clients reconnect with a cursor. Single-egress remains at the response boundary. + +**Architecture:** ResponseBus today is process-local (`_agent_bus_registry`, session queues). Keep bus as fan-out, not source of truth. Delivery is at-least-once; message ids + sequence make dedup deterministic. ReplyAction / EgressGate stay the sole final-text authority (ADR-0014, ADR-0024/0025). + +Durable transport choice is **deferred** (milestone future). First implementation: jvspatial-backed session event log. Document if a later transport is swapped behind the same envelope. + +--- + +## Files + +| File | Change | +|---|---| +| `jvagent/action/response/response_bus.py` | Append to outbox before notify | +| `jvagent/action/response/streaming.py` | Cursor replay, not only in-memory `max_replay` | +| Channel adapters | Consume outbox; idempotent send using message id | +| `jvagent/action/orchestrator/egress.py` | Unchanged authority; events after gate | +| `tests/conformance/test_delivery_replay.py` | HC-04 | +| Existing | `tests/action/response/test_streaming_dedup.py`, `test_emitted_latch.py` | + +--- + +### Task 1: Event envelope + +- [ ] Per-session monotonic sequence, cursor, message id, correlation id, snapshot_id +- [ ] Persist before fan-out + +### Task 2: SSE + channels + +- [ ] Reconnect replays missed frames in order +- [ ] Dedup overlapping replay (existing streaming_dedup tests still pass) +- [ ] Replace process-local proactive delivery with outbox worker or catch-up + +### Task 3: Cross-worker delivery + +- [ ] Reply created on worker A deliverable by worker B (characterization; full two-worker in HP-07) + +**Acceptance:** reconnecting clients replay in order without duplicate rendered messages; reply can be delivered off the creating worker. + +**Do not:** weaken single-egress. **Do not:** claim exactly-once channel send without adapter idempotency. diff --git a/.planning/phases/04-distributed-and-extensibility/04-01-PLAN.md b/.planning/phases/04-distributed-and-extensibility/04-01-PLAN.md new file mode 100644 index 00000000..a204ea36 --- /dev/null +++ b/.planning/phases/04-distributed-and-extensibility/04-01-PLAN.md @@ -0,0 +1,47 @@ +# HP-07 — Active-active coordination + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Phase:** 4 — Distributed runtime and extensibility +**Package:** HP-07 +**Requirements:** DIST-01, DIST-02 +**Depends on:** HP-02, HP-04, HP-06 +**Goal:** Two workers serve different sessions concurrently, coordinate same-session ownership, and survive worker loss without lost or cross-delivered events. Single-process fallback stays documented and narrower. + +**Architecture:** Durable lease/lock/ownership on supported stores. Circuit-breaker and admission state behind optional shared backends (`MODEL_BREAKER` is process-wide today). Graceful drain: stop admissions, transfer or mark active runs, continue delivery replay. + +Do **not** require Redis/Dynamo for every deploy. JSON/SQLite single-writer remains valid with explicit guarantee matrix (HP-12). + +--- + +## Files + +| File | Change | +|---|---| +| `jvagent/core/distributed_lease.py` | Session/turn ownership; drop silent in-proc-as-if-distributed | +| `jvagent/action/model/resilience.py` | Optional shared breaker backend | +| `jvagent/cli/` / runbooks | Drain protocol | +| `tests/conformance/` two-worker lane | HC-05, HC-06 | +| `docs/` | Single-process vs active-active guarantee split | + +--- + +### Task 1: Leases + +- [ ] Same-session ownership renewable while turn runs +- [ ] Distinct sessions on two workers: no cross-talk +- [ ] Expiry: mark `recovery_required` or transfer — never silent dual writers + +### Task 2: Shared optional state + +- [ ] Breaker/admission: shared backend or documented process-local +- [ ] Unsupported combo fails closed in docs, not by accident + +### Task 3: Drain and worker loss + +- [ ] Drain: stop admissions, complete or mark runs, keep outbox replay +- [ ] Worker kill: queued delivery preserved; active runs resume or marked + +**Acceptance:** two-worker test covers concurrent users, session contention, worker loss, proactive delivery. + +**Do not:** claim this for JSON adapter without stating single-writer. diff --git a/.planning/phases/04-distributed-and-extensibility/04-02-PLAN.md b/.planning/phases/04-distributed-and-extensibility/04-02-PLAN.md new file mode 100644 index 00000000..d874a015 --- /dev/null +++ b/.planning/phases/04-distributed-and-extensibility/04-02-PLAN.md @@ -0,0 +1,50 @@ +# HP-08 — HostCapabilityProvider reference implementation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Phase:** 4 — Distributed runtime and extensibility +**Package:** HP-08 +**Requirements:** HOST-01, HOST-02 +**Depends on:** HP-03, HP-05 (not HP-09) +**Goal:** Generic provider protocol + local reference provider. Host tools/skills materialize through snapshots only. Embedded and remote adapters share identical contract tests. jvagent never learns the host data model. + +**Architecture:** Replace `register_host_skill_provider` (process-global, agent-only, Integral-mentioned in docstring). Orchestrator calls `resolve_snapshot` / `invoke` / `load_skill` / `invalidate`. Authority bound server-side; stripped from model payloads. + +Reference native fixture: `examples/jvagent_app`. +Host fixture: `tests/conformance/fixtures/fake_host/` — tiny independent host, **not** Integral workspaces/Apps. + +--- + +## Files + +| File | Change | +|---|---| +| `jvagent/harness/provider.py` | Protocol + local reference provider | +| `jvagent/embed/` | Embedded transport adapter | +| Remote adapter module | Same contract over HTTP or equivalent | +| `jvagent/action/orchestrator/skill_providers.py` | Delete or shim-to-protocol; no global list | +| `jvagent/action/orchestrator/*` | Consume snapshot only | +| `tests/conformance/test_provider_contract.py` | Parametrize native / embedded / remote | + +--- + +### Task 1: Protocol + local provider + +- [ ] Implement HP-00 methods +- [ ] Local provider serves per-session dynamic tools and skills +- [ ] Authority maps on server; invoke rejects client-supplied capability tokens + +### Task 2: Adapters + +- [ ] Embedded: in-process, identical types +- [ ] Remote: same types on the wire +- [ ] Identical conformance cases + +### Task 3: Revocation + +- [ ] `invalidate` → next `resolve_snapshot` omits revoked tools/skills +- [ ] In-flight snapshot behavior matches HP-00 (keep vs abort) + +**Acceptance:** sample host supplies per-session dynamic tools/skills; revocation at next snapshot; no host schema in jvagent. + +**Forbidden:** `workspace_id` in jvagent models; importing host services into Orchestrator; using Integral as the test host. diff --git a/.planning/phases/04-distributed-and-extensibility/04-03-PLAN.md b/.planning/phases/04-distributed-and-extensibility/04-03-PLAN.md new file mode 100644 index 00000000..eb8ed938 --- /dev/null +++ b/.planning/phases/04-distributed-and-extensibility/04-03-PLAN.md @@ -0,0 +1,54 @@ +# HP-09 — Skill package and execution hardening + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Phase:** 4 — Distributed runtime and extensibility +**Package:** HP-09 +**Requirements:** SKIL-01, SKIL-02 +**Depends on:** HP-03, HP-05 (parallel with HP-08) +**Goal:** Signed skill manifest; JV and Claude `SKILL.md` remain the only forms; selectable isolation backends; stage per NativeCaller + snapshot with deterministic cleanup. + +**Architecture:** Keep ADR-0017 two specs. `SubprocessExecutor` stays development-only containment — document that clearly. Untrusted script skills refuse unless an approved isolation backend is configured. + +Staging today: `code_execution_action.stage_skill` into per-user sandbox. Key staging by snapshot digest so a revoked/changed skill cannot run stale. + +--- + +## Files + +| File | Change | +|---|---| +| Skill manifest module | source, digest, declared tools, requested capabilities, trust tier, signature | +| `jvagent/scaffold/skill_resolve.py` | Verify digest at resolve | +| `jvagent/action/code_execution/` | Isolation backend selection; refuse untrusted without backend | +| `jvagent/core/sandbox.py` | Stage path includes snapshot/digest; cleanup | +| `jvagent/action/orchestrator/skill_tasks.py` | Activate only if snapshot admits the digest | +| `docs/` | Subprocess ≠ sandbox | +| Tests | Reproducible activate; stale snapshot refuse | + +--- + +### Task 1: Manifest + +- [ ] Fields: source, digest, declared tools, capabilities, trust tier +- [ ] Activation reproducible from digest + +### Task 2: Two SKILL.md forms only + +- [ ] `spec: jv` and `spec: claude` unchanged as authoring sources +- [ ] No third skill format + +### Task 3: Isolation + +- [ ] Selectable backends for script-bearing Claude skills +- [ ] Untrusted + no approved backend → refuse +- [ ] Docs: subprocess limits are dev-only + +### Task 4: Staging lifecycle + +- [ ] Stage per NativeCaller + snapshot +- [ ] Deterministic cleanup +- [ ] Audit record of stage/activate/refuse +- [ ] Changed/revoked skill cannot run under stale snapshot + +**Acceptance:** digest-reproducible activation; stale snapshot cannot run revoked/changed skill; untrusted scripts refused without approved backend. diff --git a/.planning/phases/05-operational-excellence/05-01-PLAN.md b/.planning/phases/05-operational-excellence/05-01-PLAN.md new file mode 100644 index 00000000..2c40cc06 --- /dev/null +++ b/.planning/phases/05-operational-excellence/05-01-PLAN.md @@ -0,0 +1,45 @@ +# HP-10 — Trace, replay, and evaluation plane + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Phase:** 5 — Operational excellence and release proof +**Package:** HP-10 +**Requirements:** OBSV-01 +**Depends on:** HP-04, HP-05, HP-06 +**Goal:** One correlation id explains a completed or failed turn. Redacted replay can reproduce a run against test models and tool doubles. CUCS covers tool selection, skill activation, safety, recovery, uniqueness. No other user's data enters the evidence. + +**Architecture:** Extend existing logging (`jvagent/logging/`, CUCS in `jvagent/testing/`, ADR-0027). Do not build a second analytics warehouse. Redact secrets and foreign-user content at write time. + +--- + +## Files + +| File | Change | +|---|---| +| Logging / observability | Correlated spans: admission, model tick, tool invoke, event append, delivery, retry, recovery | +| Replay format | Redacted run document + doubles | +| `jvagent/testing/` | CUCS scenarios for HC dimensions | +| `tests/conformance/` | Isolation of evidence by NativeCaller | + +--- + +### Task 1: Traces + +- [ ] Correlation id from HP-02 admission through outbox +- [ ] Spans for the seven events in the package brief +- [ ] Query by correlation id returns one caller only + +### Task 2: Replay + +- [ ] Redacted format +- [ ] Replay against test model + tool doubles +- [ ] Fixture proves no other-user content + +### Task 3: CUCS evals + metrics + +- [ ] Tool selection, skill activation, safety, recovery, response uniqueness +- [ ] Latency, token/cost, tool success, duplicate delivery, recovery time, snapshot cache behavior + +**Acceptance:** operator explains any turn from one correlation id without another user's data. + +**Reuse:** `.planning/reference/conversation-use-cases.md`, `jvagent/testing/live_runner.py`. diff --git a/.planning/phases/05-operational-excellence/05-02-PLAN.md b/.planning/phases/05-operational-excellence/05-02-PLAN.md new file mode 100644 index 00000000..66780d35 --- /dev/null +++ b/.planning/phases/05-operational-excellence/05-02-PLAN.md @@ -0,0 +1,45 @@ +# HP-11 — Performance and capacity work + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Phase:** 5 — Operational excellence and release proof +**Package:** HP-11 +**Requirements:** PERF-01 +**Depends on:** HP-03, HP-06, HP-07 +**Goal:** Measure snapshot creation, graph session load, streaming fan-out, long-session pruning, parallel tool execution. Set budgets. Publish deployment profiles. No optimization weakens ordering, identity isolation, or egress. + +**Architecture:** Benchmarks as pytest (existing `tests/` patterns). Indexes/pagination for journal and event queries on supported jvspatial stores only. Profiles: local, single-worker, active-active. + +--- + +## Files + +| File | Change | +|---|---| +| `tests/` benchmarks | Named benches for the five hot paths | +| Journal/event query paths | Indexes + pagination where adapter supports | +| Config / docs | Budgets: catalogue size, event retention, observation size, session backlog | +| Runbooks | local / single-worker / active-active profiles | + +--- + +### Task 1: Benchmarks + +- [ ] Snapshot creation +- [ ] Graph session load (`get_session` + TurnRun) +- [ ] Streaming fan-out +- [ ] Long-session pruning (ADR-0003 still bounded) +- [ ] Parallel tool execution + +### Task 2: Store support + +- [ ] Journal/event query pagination +- [ ] Indexes on supported backends; mark others unsupported (HP-12) + +### Task 3: Budgets + profiles + +- [ ] Numeric budgets in config +- [ ] Three deployment profiles +- [ ] Guard tests: optimization cannot skip outbox append or snapshot key + +**Acceptance:** targets measured under representative many-user/many-session load; ordering/identity/egress still hold. diff --git a/.planning/phases/05-operational-excellence/05-03-PLAN.md b/.planning/phases/05-operational-excellence/05-03-PLAN.md new file mode 100644 index 00000000..a44f5ad2 --- /dev/null +++ b/.planning/phases/05-operational-excellence/05-03-PLAN.md @@ -0,0 +1,45 @@ +# HP-12 — Release and compatibility evidence + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Phase:** 5 — Operational excellence and release proof +**Package:** HP-12 +**Requirements:** REL-01 +**Depends on:** all prior packages +**Goal:** Version public contracts, run every evidence lane against a release artifact, publish a deployment matrix, mark unsupported combinations explicitly. + +**Architecture:** Release record is a dated artifact (digest + contract versions + topology + evidence pointers + limitations + rollback). Not a marketing doc. Process-local behavior is never implied as a distributed guarantee. + +--- + +## Files + +| File | Change | +|---|---| +| Contract version tags | NativeCaller / snapshot / provider / event envelope versions | +| `docs/` migration guide | Breaking changes from v1 process-local assumptions | +| Deployment matrix | Backend × execution mode → HC-01…HC-12 | +| Release record template | Digest, versions, topology, evidence, limits, rollback | +| CI lanes | unit, integration, conformance, two-worker, crash-recovery, skill-isolation, load | + +--- + +### Task 1: Version contracts + +- [ ] Public contract versions +- [ ] Migration guidance from v1 (bus, caches, host skill provider) + +### Task 2: Evidence lanes + +- [ ] Run all listed lanes against the release artifact +- [ ] Fail the record if a claimed HC lacks a passing lane + +### Task 3: Matrix + +- [ ] Rows: JSON / SQLite / Mongo / Dynamo (and postgres if in-tree) +- [ ] Columns: local, single-worker, active-active +- [ ] Cells: guaranteed / unsupported / degraded — never blank + +**Acceptance:** release record identifies artifact digest, contract versions, supported topology, evidence, limitations, rollback path. + +**Do not:** document a guarantee the corresponding HP test did not pass. diff --git a/AGENTS.md b/AGENTS.md index 9b25fd78..004fdedb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,5 @@ # AGENTS.md See [CLAUDE.md](CLAUDE.md) — same agent guide, alternate filename for non-Claude AI agents (Codex CLI, Gemini CLI, etc.). + +## Imported Claude Cowork project instructions diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4700b6..b4ceacc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) / ### Added +- **Harness excellence runtime (HP-02 … HP-12).** `jvagent.harness.runtime` is the store-backed source of truth for NativeCaller admission, snapshot-keyed caches, TurnRun journals, invocation ledger, durable outbox, session leases, host providers, skill manifests/isolation, traces, and the HP-12 deployment matrix. Process-local bus/caches remain fan-out; JSON/SQLite active-active is unsupported. Docs: `docs/HARNESS_DEPLOYMENT.md`, `docs/skill-isolation.md`. TurnRun checkpoints persist on `Interaction.observability_metrics`; loop resume skips completed IDEMPOTENT invocations; Claude skill staging is snapshot/digest-keyed and refuses untrusted isolation; mutating send/delete/bash tools declare `NON_RETRYABLE`; embed cancel marks TurnRun recovery. HostCapabilityProvider.invoke dispatches a registered host runner; IsolatedExecutor wraps approved backends with no subprocess fallback; dump_store/load_store persist the harness store; file/redis/dynamo lease adapters require an explicit client; skill signatures use HMAC compare_digest; CUCS harness evals live under `tests/conformance/cucs/`; CI adds conformance/two-worker/isolation/load lanes. + +- **Harness baseline audit (HP-01).** Process-local bus, caches, breakers, and locks inventoried in `.planning/phases/01-contracts-and-baseline/PROCESS-LOCAL-STATE.md` with characterization tests. No replacements. + +- **Harness contract freeze (ADR-0054, HP-00).** `jvagent.harness.contracts` defines `NativeCaller`, TurnRun transitions, `ToolSurfaceSnapshot`, invocation/event envelopes, and `HostCapabilityProvider`. Host-domain fields and model-supplied authority keys are rejected. Conformance suite at `tests/conformance/` (`harness_conformance` marker). + - **Opt-in `[EVENT]` lines in loop history (ADR-0053).** The Orchestrator's `with_event` attribute (default `false`, resolvable per channel via `channel_overrides`) feeds `[EVENT]` annotations from PRIOR interactions into @@ -27,6 +33,13 @@ and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) / ### Changed +- **ResponseBus now enforces the single-egress latch.** The first delivered + non-transient user stream chunk marks its `Interaction` as emitted, active + chunks may finish that same stream, and any later independent user publish + for the turn is suppressed at the framework delivery boundary. This fixes + duplicate assistant bubbles without requiring consumers to compare or + normalize response text. + - **Defaults that permit long-running, deep-thinking models (#214).** Three shipped defaults combined to end a reasoning model's turn before it could answer, and the user-facing text ("I got stuck repeating a step") pointed at @@ -73,6 +86,22 @@ and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) / `Test jvagent` run has succeeded. The `jvchat` job has a 20-minute job timeout, an 8-minute install timeout and one `npm ci` retry. +### Fixed + +- **Atomic per-interaction egress claim across ResponseBus instances.** A + process-wide `InteractionEgressRecord` (keyed by `interaction_id`) is the + single durable latch for user delivery and `message_type=final`. Rematerialized + `Interaction` objects and a second bus instance can no longer emit a second + Hello. Fresh session → Hello → exactly one persisted response and one + delivered final (`test_atomic_final_emission.py`). + +- **One assistant identity per streamed turn.** Non-stream `publish()` while a + user accumulator is open is suppressed (it minted a new Object id, which + Integral splits into a second bubble). `finalize_interaction` no longer + emits a second `message_type=final` under a fresh id when the stream already + finalized. `commit_pending_adhoc` reuses `acc.message_id`. jvchat merges a + same-id adhoc flush into the in-flight stream row. + ### Added - **Nightly reproduction of the #203 failure.** `scripts/live_smoke.py` gains diff --git a/CLAUDE.md b/CLAUDE.md index 01884e8d..6d405492 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ Use cases: turn-based chatbots, channel adapters (WhatsApp / Messenger / email / |---|---| | **Navigate the design docs** | [`.planning/README.md`](.planning/README.md) (folder index) | | **Get the big picture** | [`.planning/PROJECT.md`](.planning/PROJECT.md) | +| **v2.0 Harness Excellence** | [`.planning/ROADMAP.md`](.planning/ROADMAP.md) · [`.planning/REQUIREMENTS.md`](.planning/REQUIREMENTS.md) · [`docs/HARNESS_EXCELLENCE_PLAN.md`](docs/HARNESS_EXCELLENCE_PLAN.md) | | **Look up normative semantics** (invariants, contracts) | [`.planning/SPEC.md`](.planning/SPEC.md) | | **Choose a deployment pattern** (Orchestrator) | [`.planning/PATTERNS.md`](.planning/PATTERNS.md) | | **See diagrams** (boot, interact, executive, pruning) | [`.planning/architecture.md`](.planning/architecture.md) | @@ -222,7 +223,9 @@ pytest tests/ # or the affected slice(s) at minimum ## 9. Roadmap and in-flight work -- Orchestrator design + roadmap: [`.planning/adr/0012-skill-executive-architecture.md`](.planning/adr/0012-skill-executive-architecture.md), [`.planning/archive/EXECUTIVE-ROADMAP.md`](.planning/archive/EXECUTIVE-ROADMAP.md). +- **v2.0 Harness Excellence** (active): [`.planning/ROADMAP.md`](.planning/ROADMAP.md), [`.planning/REQUIREMENTS.md`](.planning/REQUIREMENTS.md), [`docs/HARNESS_EXCELLENCE_PLAN.md`](docs/HARNESS_EXCELLENCE_PLAN.md). +- Orchestrator design: [`.planning/adr/0012-skill-executive-architecture.md`](.planning/adr/0012-skill-executive-architecture.md). +- v1 history: [`.planning/archive/EXECUTIVE-ROADMAP.md`](.planning/archive/EXECUTIVE-ROADMAP.md). - ADRs: [`.planning/adr/`](.planning/adr/). --- diff --git a/README.md b/README.md index 466d8209..31939b60 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,7 @@ jvagent resolves configuration by precedence (highest first): - [Environment keys reference](https://github.com/TrueSelph/jvagent/blob/main/docs/environment-keys-reference.md) — every `JVAGENT_*` / `JVSPATIAL_*` / vendor key - [App scaffolding CLI](https://github.com/TrueSelph/jvagent/blob/main/docs/scaffolding.md) — `jvagent app create`, `agent create`, `app profile new` - [Language models](https://github.com/TrueSelph/jvagent/blob/main/docs/language-models.md) — provider actions, retries, model gearing +- [Harness excellence roadmap](docs/HARNESS_EXCELLENCE_PLAN.md) — host-neutral reliability and extensibility roadmap - [Database indexing](https://github.com/TrueSelph/jvagent/blob/main/docs/database-indexing.md) · [Security review](https://github.com/TrueSelph/jvagent/blob/main/docs/security-review.md) - [Logging](https://github.com/TrueSelph/jvagent/blob/main/docs/logging.md) · [Interaction logging](https://github.com/TrueSelph/jvagent/blob/main/docs/interaction-logging.md) · [Error logging](https://github.com/TrueSelph/jvagent/blob/main/docs/error-logging.md) - [Task tracking](https://github.com/TrueSelph/jvagent/blob/main/docs/task-tracking.md) · [Proactive messages](https://github.com/TrueSelph/jvagent/blob/main/docs/proactive-messages.md) diff --git a/docs/HARNESS_DEPLOYMENT.md b/docs/HARNESS_DEPLOYMENT.md new file mode 100644 index 00000000..3ae84789 --- /dev/null +++ b/docs/HARNESS_DEPLOYMENT.md @@ -0,0 +1,55 @@ +# Harness deployment matrix (HP-12) + +Contract version: `jvagent.harness.contracts.CONTRACT_VERSION` (`1.0.0`). + +Native identity, snapshot, provider, and event-envelope versions share that tag. +See `jvagent.harness.release.release_record()`. + +## Guarantee split + +Process-local caches, buses, and in-process leases are **not** distributed +guarantees. JSON and SQLite remain single-writer. Active-active requires a +shared `HarnessStore` (tests) or Redis/Dynamo session leases (HP-07). + +Same-session policy is **lease** (`SAME_SESSION_POLICY`). A second worker that +does not hold the lease is refused (`SessionBusy`). Drain stops admissions and +keeps outbox replay. + +Lease backends: in-process (default), `FileLeaseBackend` (JSON + `os.replace`), +optional Redis `SET NX EX` and Dynamo `put_item` adapters. Missing Redis/Dynamo +clients raise; they do not silently fall back. + +Durable store dump: `jvagent.harness.persist.dump_store` / `load_store` writes +identities, snapshots, journals, ledger, outbox, traces, skills, and leases to +JSON. Callables (host runners, compensators) are not restored. + +Retention: `HarnessRuntime.prune_retention()` clips outbox and traces to +`MAX_EVENTS_PER_SESSION` / `max_trace_spans`. Journal and event reads paginate +(`journal_entries`, `replay_from(..., limit=)`). + +## Matrix + +Cells are `guaranteed` / `degraded` / `unsupported`. Never blank. + +| Backend | local | single-worker | active-active | +|---|---|---|---| +| json | guaranteed | guaranteed | unsupported | +| sqlite | guaranteed | guaranteed | unsupported | +| mongodb | guaranteed | guaranteed | degraded | +| dynamodb | guaranteed | guaranteed | guaranteed | +| postgres | guaranteed | guaranteed | degraded | + +`degraded` means identity upsert and outbox work in-process / via the harness +store, but cluster leases are not the Dynamo/Redis backends unless configured. + +## Migration from v1 process-local assumptions + +- Tool/skill caches are keyed by `snapshot_id` + NativeCaller, not `agent_id` alone. +- ResponseBus remains fan-out; durable order lives on the outbox (`HarnessStore.outbox`). +- `register_host_skill_provider` is a shim. Hosts should put session tools/skills on the runtime and serve them through `HostCapabilityProvider`. +- Embed `_interact_tasks` is still a cancel handle. Turn truth is the TurnRun journal. + +## Rollback + +Revert to process-local caches/bus; disable drain and shared store. See +`release_record()["rollback"]`. diff --git a/docs/HARNESS_EXCELLENCE_PLAN.md b/docs/HARNESS_EXCELLENCE_PLAN.md new file mode 100644 index 00000000..af8cfe32 --- /dev/null +++ b/docs/HARNESS_EXCELLENCE_PLAN.md @@ -0,0 +1,368 @@ +# jvagent harness excellence plan + +**Prepared:** 2026-09-17 +**Status:** accepted as GSD milestone v2.0 — Phases 1–5 implemented; see [`.planning/ROADMAP.md`](../.planning/ROADMAP.md) +**Audience:** jvagent maintainers and host-integration authors +**Execution model:** bounded coding-agent work packages with contract-first handoffs; one PLAN.md per HP under `.planning/phases/` + +## 1. Aim + +jvagent should be the dependable, graph-native harness for applications that need many agents, many users, and many simultaneous sessions without sacrificing model agency or Claude-skill compatibility. + +The goal is not feature-count parity with other agent products. The goal is a stronger harness contract: + +- Every turn has an isolated, durable identity. +- Every user-visible event has an ordered, replayable delivery record. +- Every side effect has an idempotency and recovery story. +- Every skill and tool is attributable, capability-limited, and safe to materialize for one caller. +- Every host can dock a dynamic tool and skill surface without jvagent learning the host's domain model. +- Every production claim is supported by fault, concurrency, and recovery evidence. + +The model remains the pilot; tools remain controls; skills remain the flight plan. This roadmap makes the airframe reliable under failure and scale. + +## 2. Non-negotiable architectural boundaries + +### 2.1 Native multitenancy remains native + +jvagent's persistent identity is already sufficient for host-neutral multitenancy: + +```text +Agent → Memory → User(user_id) → Conversation(session_id) → Interaction +``` + +The canonical isolation key is therefore: + +```text +(agent_id, user_id, session_id) +``` + +No `workspace_id`, organization model, App model, or other host product concept enters jvagent core. A host that has multiple logical scopes maps them to separate session ids and retains its scope map itself. jvagent passes its native identity to an optional host provider; the provider resolves host-specific authority outside the harness. + +### 2.2 Thin harness stays thin + +This plan does not add semantic routing, intent classification, domain extraction, or business workflows to the Orchestrator. Reliability mechanics belong in the harness; judgment belongs in skills and the model. + +### 2.3 jvspatial remains the runtime state substrate + +Durable run state, session state, tasks, delivery state, and graph-native memory use jvspatial primitives. A durable event transport, cache invalidation layer, or execution backend may be introduced behind protocols, but must not fork a second, competing business-state model. + +### 2.4 Hosts extend through a narrow provider protocol + +An embedded application can supply dynamic tools, skills, and grounding through a generic `HostCapabilityProvider`. jvagent sees only agent/user/session identity plus an opaque snapshot version; it never imports a host's services, graph models, or authorization code. + +## 3. Current strengths to preserve + +| Capability | Existing foundation | Preserve by | +| --- | --- | --- | +| Multi-user state | User uniqueness within an agent Memory graph; session-keyed Conversations | Keeping `agent_id + user_id + session_id` authoritative | +| Graph-native execution | Actions, tasks, conversations, and interactions are jvspatial graph participants | Adding structural nodes and edges, not parallel tables without lifecycle semantics | +| Model-led orchestration | Bounded think-act-observe loop; routing through tool choice | Keeping reliability mechanisms independent of semantic decisions | +| Skills | Native JV SOPs and drop-in Claude skill bundles | Maintaining `SKILL.md` as the authoring source and progressive disclosure | +| Tools | Native JSON-schema tool protocol, access checks, and dynamic surface | Adding per-invocation authority and result envelopes rather than bypass paths | +| Resilience | Model fallback, circuit breaking, budgets, response egress gate | Making state shared and recoverable across workers | +| Channels | ResponseBus and channel adapters | Replacing process-local delivery assumptions with durable delivery semantics | + +## 4. Gaps to close + +| Gap | Why it matters | Required result | +| --- | --- | --- | +| Process-local delivery and caches | A response or tool/skill surface can be absent or stale on another worker | Ordered, durable events and scope-safe snapshot caches | +| Soft plan resumption | A checklist survives, but in-memory observations and side-effect certainty do not | Checkpointed turn run with idempotent tool execution | +| Cross-worker identity races | Local locks do not prove active-active correctness | Store-backed identity upsert and renewable leases | +| Host integration via bespoke adapters | Each host risks coupling and cache leakage | One generic host capability protocol | +| Skill execution trust | Per-user sandboxing is useful but default subprocess isolation is not a hard security boundary | Signed manifests, capability limits, and selectable isolation backends | +| Observability | Logs explain parts of a run but do not reconstruct a reliable execution history | Correlated run/event/tool/delivery trace and replay tooling | +| Reliability evidence | Happy-path tests do not prove recovery or concurrency | Fault injection, crash recovery, multi-worker, and load suites | + +## 5. Target runtime model + +```mermaid +flowchart LR + C[Client] --> I[Interact endpoint] + I --> R[TurnRun journal] + R --> O[Orchestrator] + O --> S[Tool and skill snapshot] + S --> T[Native or host-provided tool] + T --> R + O --> E[Durable event outbox] + E --> D[Response delivery and SSE replay] + R --> M[Conversation and task graph] + H[Optional HostCapabilityProvider] --> S +``` + +### 5.1 TurnRun + +Introduce a graph-backed `TurnRun` record associated with one Interaction. It is execution metadata, not a second conversation model. + +```text +accepted → running → waiting_tool → waiting_approval → running + → completed | failed | cancelled | recovery_required +``` + +Each transition carries a monotonic sequence number, timestamp, reason, snapshot version, and correlation id. The journal stores safe checkpoints and references to larger observations; it does not persist unrestricted model chain-of-thought. + +### 5.2 Tool execution record + +Every mutating tool call receives a stable `invocation_id` before dispatch. The runtime persists: + +- normalized tool name and validated input digest; +- idempotency key and dispatch attempt; +- authority/snapshot version used; +- outcome, error classification, and output reference; +- causal links to TurnRun, Interaction, and delivery events. + +Tool authors remain responsible for domain-level exactly-once semantics, but the harness provides the durable invocation identity they need to implement it. + +### 5.3 Event and delivery record + +All streaming frames and final responses are appended to a durable per-session event stream before fan-out. Clients reconnect using a cursor. Delivery is at-least-once; message ids and event sequence make client and adapter deduplication deterministic. The single-egress invariant remains enforced at the response boundary. + +### 5.4 Tool and skill snapshot + +At turn admission, the Orchestrator receives one immutable `ToolSurfaceSnapshot`: + +```text +snapshot_id +agent_id, user_id, session_id +native tool and skill descriptors +optional host-provided descriptors +trust/capability policy +created_at and expiry +``` + +Every cache key includes `snapshot_id`; no process-global cache may serve a tool or skill document outside its snapshot. A later turn may receive a newer snapshot. In-flight turns continue against the snapshot admitted at their start unless a host explicitly revokes it. + +### 5.5 Generic host capability provider + +The optional protocol is intentionally host-neutral: + +```text +resolve_snapshot(agent_id, user_id, session_id) -> ToolSurfaceSnapshot +invoke(snapshot_id, invocation_id, tool_name, payload) -> ToolResult +load_skill(snapshot_id, skill_key) -> SkillMaterialization +invalidate(snapshot_selector) -> acknowledgement +``` + +The provider resolves all host-specific scope and authorization privately. jvagent only enforces snapshot lifetime, tool schema, invocation identity, and its own action-level access gates. + +## 6. Execution packages + +### Wave 0 — contracts and baseline evidence + +#### HP-00: Harness contract ADRs and conformance suite + +**Ownership:** architecture/runtime +**Files:** new ADRs, `SPEC.md`, `docs/ORCHESTRATOR.md`, `tests/conformance/` + +- Define `TurnRun`, tool invocation, event, snapshot, and provider contracts. +- Specify delivery, cancellation, retry, idempotency, and recovery semantics. +- Publish the non-leakage rule: caches, tools, skills, and events are keyed by native identity plus snapshot. +- Establish a conformance suite runnable by native, embedded, and remote integrations. + +**Acceptance:** contract fixtures define both valid and rejected transitions; no host-domain field appears in public jvagent models or APIs. + +#### HP-01: Baseline reliability audit + +**Ownership:** test/observability +**Depends on:** HP-00 + +- Inventory all process-local state: ResponseBus, tool catalogues, skill catalogues, circuit breakers, locks, and background work. +- Record current failure behavior for restart, duplicate delivery, concurrent session turns, model error, and interrupted tool calls. +- Add benchmark fixtures for short chat, tool-rich chat, streaming, long session, and many-user concurrency. + +**Acceptance:** each process-local component has an owner, scope, replacement decision, and regression test target. + +### Wave 1 — identity, snapshots, and safe caching + +#### HP-02: Native identity and session admission + +**Ownership:** memory/interact +**Depends on:** HP-00 + +- Formalize `(agent_id, user_id, session_id)` as the native admission identity. +- Add store-backed upsert-by-identity for User and Conversation where supported. +- Make concurrent session admission and turn ownership explicit, including cancellation and lease expiry. +- Add stable correlation ids from endpoint through background work and response delivery. + +**Acceptance:** concurrent creates across workers produce one User and one Conversation; simultaneous turns on distinct sessions remain isolated; same-session policy is explicit and tested. + +#### HP-03: ToolSurfaceSnapshot and cache discipline + +**Ownership:** orchestrator/tools/skills +**Depends on:** HP-00, HP-02 + +- Replace scope-blind merged tool and skill caches with immutable snapshots. +- Key caches by snapshot id and invalidate by generation rather than clearing process globals per turn. +- Attach snapshot identity to model calls, tool calls, events, and traces. +- Preserve lean discovery (`find_tool`, `load_tool`, `find_skill`, `use_skill`) using snapshot-scoped catalogues. + +**Acceptance:** two concurrent users and sessions receive only their own snapshots; dynamic tool/skill changes affect a new snapshot without contaminating any other caller. + +### Wave 2 — durable turns and exactly-once-aware execution + +#### HP-04: TurnRun journal and resumable execution + +**Ownership:** orchestrator/memory +**Depends on:** HP-02 + +- Persist lifecycle transitions at tool and safe loop boundaries. +- Save plan state, current phase, admitted snapshot id, and safe observation references. +- Resume a recoverable run after process loss without rerunning completed tool invocations. +- Add explicit `recovery_required` for unsafe interruption rather than silently replaying work. + +**Acceptance:** a crash after tool dispatch is diagnosable and recoverable; completed read tools are not repeated unnecessarily; unsafe writes require an explicit, visible recovery decision. + +#### HP-05: Invocation ledger and idempotency adapters + +**Ownership:** tool execution/actions +**Depends on:** HP-04 + +- Allocate `invocation_id` before every dispatch. +- Require mutating native tools to declare idempotency behavior. +- Add wrappers for idempotent, compensatable, and non-retryable actions. +- Preserve native tool calling, parallel sibling-tool dispatch, and action access checks. + +**Acceptance:** retries reuse the invocation identity; a duplicate dispatch cannot duplicate a supported mutating effect; non-retryable tools produce a typed recovery state. + +#### HP-06: Durable event outbox and resumable streaming + +**Ownership:** response/channels +**Depends on:** HP-04 + +- Append outbound frames to a durable session stream before adapter delivery. +- Add event cursors and replay for SSE and channel adapters. +- Replace process-local proactive delivery assumptions with an outbox worker or catch-up protocol. +- Retain the response-bus egress gate as the sole final-text authority. + +**Acceptance:** reconnecting clients replay missed frames in order without duplicate rendered messages; a reply created on one worker can be delivered by another. + +### Wave 3 — distributed runtime and secure extensibility + +#### HP-07: Active-active coordination + +**Ownership:** runtime/operations +**Depends on:** HP-02, HP-04, HP-06 + +- Provide durable lease, lock, and ownership protocols for supported stores. +- Move circuit-breaker and admission state behind optional shared backends. +- Define graceful worker drain: stop admissions, transfer or mark active runs, continue delivery replay. +- Document the single-process fallback and its guarantees separately. + +**Acceptance:** a two-worker test handles concurrent users, session contention, worker loss, and proactive delivery without lost or cross-delivered events. + +#### HP-08: HostCapabilityProvider reference implementation + +**Ownership:** integrations/SDK +**Depends on:** HP-03, HP-05 + +- Add the generic provider protocol and a local reference provider. +- Materialize host tools and skills through snapshots, never direct imports into the Orchestrator. +- Bind authority server-side and make it unavailable to model-generated payloads. +- Provide embedded and remote transport adapters with identical contract tests. + +**Acceptance:** a sample host supplies per-session dynamic tools and skills; revocation takes effect at the next snapshot; jvagent remains unaware of the host's data model. + +#### HP-09: Skill package and execution hardening + +**Ownership:** skills/code execution/security +**Depends on:** HP-03, HP-05 + +- Define a signed skill manifest: source, digest, declared tools, requested execution capabilities, and trust tier. +- Keep JV and Claude skills as the two supported `SKILL.md` forms. +- Add selectable isolation backends for script-bearing Claude skills; document subprocess limits as development-only containment. +- Stage skills per native caller identity and snapshot, with deterministic cleanup and audit. + +**Acceptance:** skill activation is reproducible from its digest; a revoked or changed skill cannot run under a stale snapshot; untrusted script skills are refused without an approved isolation backend. + +### Wave 4 — operational excellence and release proof + +#### HP-10: Trace, replay, and evaluation plane + +**Ownership:** observability/evals +**Depends on:** HP-04, HP-05, HP-06 + +- Emit correlated traces for admission, model tick, tool invocation, event append, delivery, retry, and recovery. +- Build a redacted replay format that can reproduce a run against test models and tool doubles. +- Add conversation use-case evaluations for tool selection, skill activation, safety, recovery, and response uniqueness. +- Measure latency, token/cost, tool success, duplicate delivery, recovery time, and snapshot cache behavior. + +**Acceptance:** an operator can explain any completed or failed turn from one correlation id without accessing another user's data. + +#### HP-11: Performance and capacity work + +**Ownership:** runtime/performance +**Depends on:** HP-03, HP-06, HP-07 + +- Benchmark snapshot creation, graph session load, streaming fan-out, long-session pruning, and parallel tool execution. +- Add indexes and pagination for journal/event queries on supported jvspatial stores. +- Set budgets for tool-catalogue size, event retention, observation size, and per-session backlog. +- Publish deployment profiles for local, single-worker, and active-active modes. + +**Acceptance:** performance targets are measured under representative many-user/many-session load; no optimization weakens ordering, identity isolation, or egress guarantees. + +#### HP-12: Release and compatibility evidence + +**Ownership:** release/docs +**Depends on:** all prior packages + +- Version all public contracts and publish migration guidance. +- Run full unit, integration, conformance, two-worker, crash-recovery, skill-isolation, and load lanes against release artifacts. +- Produce a deployment matrix showing guarantees by storage backend and execution mode. +- Mark unsupported combinations explicitly instead of relying on process-local behavior. + +**Acceptance:** a release record identifies artifact digest, contract versions, supported topology, evidence, limitations, and rollback path. + +## 7. Dependency map + +```text +HP-00 ── HP-01 + │ + ├── HP-02 ── HP-04 ──┬── HP-05 ──┬── HP-08 + │ │ └── HP-09 (HP-08 ∥ HP-09; both need HP-03 + HP-05) + │ ├── HP-06 ── HP-07 + │ └── HP-10 + └── HP-03 ───────────┘ + +HP-03 + HP-06 + HP-07 ── HP-11 ── HP-12 +``` + +## 8. Conformance criteria + +| ID | Result | +| --- | --- | +| HC-01 | Native identity isolates concurrent users, agents, and sessions without host-specific fields in jvagent core | +| HC-02 | A tool/skill snapshot cannot leak across sessions or be reused after expiry/revocation | +| HC-03 | A crash before, during, and after tool dispatch has an explicit recovery result and never silently duplicates a supported side effect | +| HC-04 | SSE and channel delivery replay events in order using cursors and render each final response once | +| HC-05 | Two workers can serve different sessions concurrently and coordinate same-session ownership correctly | +| HC-06 | Worker loss preserves queued delivery and either resumes or safely marks active runs for recovery | +| HC-07 | JV and Claude skill bundles materialize from verified manifests into isolated caller slices | +| HC-08 | Native, embedded-host, and remote-host tool providers pass the same invocation and revocation contract suite | +| HC-09 | Model outage, retry, fallback, budget exhaustion, cancellation, and tool timeout leave an inspectable terminal run state | +| HC-10 | Trace/replay can reconstruct one run with redaction and prove no other user's content enters its evidence | +| HC-11 | Load tests preserve p95 targets and event ordering under many users and sessions | +| HC-12 | All guarantees are tied to an exact release artifact and documented deployment profile | + +## 9. What this plan deliberately does not do + +- Add host concepts such as workspaces, organizations, Apps, or domain schemas to jvagent. +- Turn the Orchestrator into a semantic router, workflow designer, or business-rule engine. +- Replace jvspatial or create a separate memory database that competes with graph state. +- Promise exactly-once execution for third-party side effects that do not expose an idempotency mechanism. +- Treat a subprocess resource limiter as a sandbox for untrusted code. +- Require every deployment to run active-active infrastructure; single-process mode remains supported with explicitly narrower guarantees. + +## 10. Immediate next actions + +1. Accept HP-00's neutral identity and snapshot contract before any implementation begins. +2. Run HP-01 as a short audit and attach a concrete list of all process-local state. +3. Start HP-02, HP-03, and HP-04 in parallel after the contract fixtures freeze. +4. Use the existing jvagent application example as the reference harness fixture; use a small independent host fixture for HP-08 rather than embedding another product's concepts in tests. +5. Do not claim active-active or crash-safe execution until HP-06 and HP-07 evidence exists. + +## 11. Decisions to revisit after the foundation lands + +- Which durable transport is supported first for event outbox and distributed coordination. +- Whether checkpoint and event retention have separate storage policies per supported backend. +- Whether background work uses the same TurnRun executor or a closely related durable worker contract. +- Whether external skills receive a publisher registry and revocation service after signed manifests and isolation backends are proven. diff --git a/docs/ORCHESTRATOR.md b/docs/ORCHESTRATOR.md index 489fc0f7..d77ad5ca 100644 --- a/docs/ORCHESTRATOR.md +++ b/docs/ORCHESTRATOR.md @@ -35,6 +35,8 @@ Active-flow detection reads persisted state only. With `lock_active_flow=False` The orchestrator and every action on its tool surface follow the **[thin harness principle](thin-harness.md)**: the server exposes primitives (tools, session state, validation gates, raw JSON results); the model and skill SOP own intent, routing, extraction, and multi-step chaining. The orchestrator must not classify user intent, inject prep observations that pre-select tools, auto-store extracted values on skill activation, inline multi-step tool results, or post-process one action's outputs to force follow-up calls. Turn-lock ([ADR-0013](../.planning/adr/0013-togglable-deterministic-turn-lock.md)) is a mechanical surface restriction — not semantic routing. +**Admission snapshot (ADR-0054).** Target contract: one immutable `ToolSurfaceSnapshot` per turn, keyed by `snapshot_id` + `(agent_id, user_id, session_id)`. Lean discovery (`find_tool` / `load_tool` / `find_skill` / `use_skill`) stays. Host tools/skills enter only through `HostCapabilityProvider`, never via Orchestrator imports. Types: [`jvagent/harness/contracts.py`](../jvagent/harness/contracts.py). Runtime snapshot cache is HP-03; today's tool surface cache is still per-agent ([`catalog.py`](../jvagent/action/orchestrator/catalog.py)). + **SESSION CONTEXT** ([ADR-0042](../.planning/adr/0042-session-context-ground-truth.md)) is turn-stable environment ground truth (current date/time via `App.now()`, channel), injected into the system prompt each turn — the same class as the former CURRENT CHANNEL line. It is **not** prep steering: relative time must use that clock; `get_current_datetime` remains for mid-turn refresh only. Subsystem-specific rules (e.g. interviews) extend the platform doc as **profiles** — see [Interview profile](../jvagent/action/interview/docs/thin-harness.md). diff --git a/docs/skill-isolation.md b/docs/skill-isolation.md new file mode 100644 index 00000000..981d8cfd --- /dev/null +++ b/docs/skill-isolation.md @@ -0,0 +1,40 @@ +# Skill isolation (HP-09) + +jvagent accepts two SKILL.md forms only: `spec: jv` and `spec: claude` (ADR-0017). +A third format is refused at manifest register. + +## Subprocess is not a sandbox + +`SubprocessExecutor` is **development-only containment**. It is not an approved +isolation backend. Untrusted script-bearing skills refuse unless one of these +backends is configured on `HarnessRuntime(isolation_backend=...)`: + +- `gvisor` +- `firecracker` +- `nsjail` + +Trusted SOP skills (no script) activate from digest under the admitted snapshot. +With `skill_signing_key` set, `register_manifest` / `publish_manifest` require an +HMAC-SHA256 signature (`hmac.compare_digest`). `revoke_manifest` drops the digest +from the in-process registry so the next activate refuses. + +## Isolation executor + +`jvagent.harness.isolation.IsolatedExecutor` prefixes the command with `runsc` / +`firecracker` / `nsjail` **only when that binary is on PATH**. Absence is a +`SkillIsolationRefused`, never a subprocess fallback. `CodeExecutionAction.executor()` +uses `executor_for_backend(get_runtime().isolation_backend, SubprocessExecutor())`. + +## Staging + +Runtime stage record is `stage/{session_id}/{snapshot_id}/{digest}`. Filesystem copy +for `CodeExecutionAction.stage_skill` is `staged_skills/{snapshot_id[:12]}/{digest}/{name}` +when a turn snapshot is in cache; otherwise the legacy `staged_skills/{name}` dest +is kept for tests and offline staging. Cleanup is `cleanup_stage(path)`. A revoked +or expired snapshot cannot activate. Claude (`spec: claude`) activations pass +`trust_tier=untrusted` and refuse without an approved isolation backend. + +## Audit + +Stage / activate / refuse are recorded as harness spans (`skill_activate`) on +the snapshot id. Correlate with the turn via NativeCaller, not a host scope. diff --git a/jvagent/action/artifact_handler_interact_action/artifact_handler_interact_action.py b/jvagent/action/artifact_handler_interact_action/artifact_handler_interact_action.py index 5d5f94f8..3328b126 100644 --- a/jvagent/action/artifact_handler_interact_action/artifact_handler_interact_action.py +++ b/jvagent/action/artifact_handler_interact_action/artifact_handler_interact_action.py @@ -54,11 +54,32 @@ UploadItem, normalize_upload_entry, ) +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.tool_decorator import tool if False: from jvagent.action.interact.interact_walker import InteractWalker + +def _register_orchestrator_vocabulary() -> None: + """Declare vault tool results as a trusted directive source. + + ``artifact_handler__*`` results may carry ``Tell the user:`` + ``response_directive`` (ready notice + pending-question answer on + other-channel status polls). Runs at import so the orchestrator trusts + them without hardcoding this plugin. + """ + try: + from jvagent.action.orchestrator.constants import ( + register_trusted_directive_prefix, + ) + except Exception: # pragma: no cover - orchestrator optional at load + return + register_trusted_directive_prefix("artifact_handler__") + + +_register_orchestrator_vocabulary() + _AGENT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), *([".."] * 4)) if str(_AGENT_ROOT) not in sys.path: sys.path.insert(0, str(_AGENT_ROOT)) @@ -79,6 +100,34 @@ def _now_ts() -> int: _PROCESSING_STATUSES = frozenset({"queued", "processing", "pending", "submitted"}) +DOCUMENT_CONTENT_CONDITION = ( + "the user asks a content or factual question that could come from their " + "uploaded documents, asks about their saved documents, uploads, or files, " + "references a document by name, type, pronoun, or description, or continues " + "a prior document question (and/also, the last 2, the first two, ordinals " + "like second/third)" +) + +DOCUMENT_SELECTION_RULES = ( + "Document selection (mandatory when documents are listed above):\n" + "Do not answer from prior replies, [EVENT] text, or the doc_description " + "lines above. Those are only for choosing doc_name — not facts to quote.\n" + "1. If faq is not already active, first call use_skill with name faq " + "(pageindex__search is not callable until then).\n" + "2. Pick doc_name: if the user clearly names a different document (explicit " + "name or type), use that doc_name; otherwise use Active document (or the " + "listed vault name when there is one). Follow-ups include and/also, the " + "last 2, the first two, ordinals like second/third, 'the photo', 'that " + "document', and history [EVENT] lines naming that last/saved doc. Do not " + "ask which document first.\n" + "3. Then call pageindex__search with query from this user message and that " + "doc_name.\n" + "4. Description match or unscoped pageindex__search only when Active " + "document is unset and the user did not name a file.\n" + "5. Never reply with only a clarifying question before searching when " + "documents are listed." +) + def _safe_filename_segment( filename: Optional[str], *, default: str = "document" @@ -633,6 +682,7 @@ async def execute(self, visitor: "InteractWalker") -> None: "notified": False, "job_id": job_id or None, "status": "queued", + "file_url": ingest_url, } if pq: entry["pending_question"] = pq @@ -642,6 +692,7 @@ async def execute(self, visitor: "InteractWalker") -> None: "doc_name": doc_name, "status": "queued", "submitted_at": now, + "file_url": ingest_url, } if pq: pending_entry["pending_question"] = pq @@ -687,6 +738,22 @@ async def execute(self, visitor: "InteractWalker") -> None: await conversation.update_context({"artifact_handler": vault}) except Exception: pass + from .vault_events import record_vault_event, saved_document_event + + for name in queued: + await record_vault_event( + visitor, + saved_document_event( + name, pending_question=pending_question, status="processing" + ), + ) + for name in ingested: + await record_vault_event( + visitor, + saved_document_event( + name, pending_question=pending_question, status="ready" + ), + ) saved_count = len(queued) + len(ingested) kind_phrase = _media_kind_phrase(saved_items) verb_is, finished = _kind_verb_finished(kind_phrase) @@ -830,20 +897,6 @@ async def _inject_accessible_documents_parameter( vault.get("active_doc_name") or "" ).strip() - _SELECTION_RULES = ( - "Document selection (mandatory when documents are listed above):\n" - "1. Match the user's question to a doc_description; if one clearly " - "fits, call pageindex__search with that doc_name — do not ask which " - "document first.\n" - "2. If Active document is set and the question is a follow-up that " - "fits that document's description, prefer that doc_name.\n" - "3. Prefer description match over Active document when they conflict.\n" - "4. If still unclear, call pageindex__search without doc_name before " - "asking which document.\n" - "5. Never reply with only a clarifying question before searching when " - "documents are listed." - ) - if not docs: response_text = "The user currently has no saved documents." else: @@ -866,7 +919,7 @@ async def _inject_accessible_documents_parameter( ] if active_doc_name: parts.append(f"Active document: {active_doc_name}") - parts.append(_SELECTION_RULES) + parts.append(DOCUMENT_SELECTION_RULES) response_text = "\n".join(parts) else: response_text = "The user currently has no saved documents." @@ -875,12 +928,7 @@ async def _inject_accessible_documents_parameter( await visitor.add_parameter( { "scope": "orchestration", - "condition": ( - "the user asks a content or factual question that could " - "come from their uploaded documents, asks about their " - "saved documents, uploads, or files, or references a " - "document by name, type, pronoun, or description" - ), + "condition": DOCUMENT_CONTENT_CONDITION, "response": response_text, } ) @@ -1022,11 +1070,13 @@ async def register_job( agent_id: str, pending_question: Optional[str] = None, filename: Optional[str] = None, + file_url: Optional[str] = None, ) -> None: if not job_id: return question = (pending_question or "").strip() or None display_name = (filename or "").strip() or None + saved_url = (file_url or "").strip() or None index = dict(self.jvforge_job_index or {}) index[job_id] = { "job_id": job_id, @@ -1040,6 +1090,7 @@ async def register_job( "submitted_at": _utc_iso(), "notified": False, "pending_question": question, + "file_url": saved_url or "", } self.jvforge_job_index = index try: @@ -1197,6 +1248,7 @@ async def submit_ingest( agent_id=agent_id, pending_question=pending_question, filename=filename, + file_url=file_url, ) return result @@ -1293,7 +1345,10 @@ def _ingest_tool_args( args["question"] = question return args - @tool(name="artifact_handler__ingest_document") + @tool( + name="artifact_handler__ingest_document", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_ingest_document( self, visitor: Any = None, @@ -1319,7 +1374,10 @@ async def _t_list_my_documents(self, visitor: Any = None, **kwargs: Any) -> str: """List the documents the user has saved, with save age and expiry.""" return await self._dispatch_tool("list_my_documents", visitor=visitor) - @tool(name="artifact_handler__delete_document") + @tool( + name="artifact_handler__delete_document", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_delete_document( self, doc_name: str, visitor: Any = None, **kwargs: Any ) -> str: diff --git a/jvagent/action/artifact_handler_interact_action/endpoints.py b/jvagent/action/artifact_handler_interact_action/endpoints.py index 6abcefa0..79cdaaf3 100644 --- a/jvagent/action/artifact_handler_interact_action/endpoints.py +++ b/jvagent/action/artifact_handler_interact_action/endpoints.py @@ -16,7 +16,6 @@ import asyncio import json import logging -import re import time from typing import Any, Dict, List, Optional @@ -25,6 +24,21 @@ from jvspatial.api import endpoint from jvspatial.api.endpoints.response import ResponseField, success_response +from .ready_message import ( # noqa: F401 — re-export for tests / notify + _canned_ready_message, + _canned_ready_message_multi, + _empty_content_ready_message, + _file_kind_label, + _file_type_word, + _friendly_file_phrase, + _generate_ready_message, + _generate_ready_message_multi, + _ready_document_text, + _should_quote_filename, + _truncate_ready_text, + _useful_source_text, +) + logger = logging.getLogger(__name__) @@ -74,137 +88,6 @@ def _display_doc_name(entry: Dict[str, Any], payload_doc_name: str) -> str: return doc_name -_IMAGE_EXTENSIONS = frozenset( - { - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".heic", - ".heif", - ".bmp", - ".tif", - ".tiff", - } -) - -# WhatsApp / channel hash names like ``20260724_134410_c624a9f1.pdf``. -_MACHINE_FILENAME_RE = re.compile(r"^\d{8}_\d{6}_[0-9a-fA-F]{6,}(\.[A-Za-z0-9]+)?$") - -# Short or ambiguous filenames that should NOT be quoted in user-facing messages. -_GENERIC_NAMES = frozenset( - { - "edit", - "file", - "upload", - "document", - "image", - "photo", - "pic", - "img", - "scan", - "download", - "attachment", - "temp", - "tmp", - "test", - "untitled", - "new", - "copy", - "backup", - } -) - - -def _should_quote_filename(display_doc: str) -> bool: - """Decide whether to quote the filename in a user-facing message. - - Machine hash names and short/generic names are not quoted — the type word - (PDF, image, document, …) is used instead. - """ - name = (display_doc or "").strip() - if not name or _MACHINE_FILENAME_RE.match(name): - return False - base = name.rsplit(".", 1)[0] if "." in name else name - if base.lower() in _GENERIC_NAMES: - return False - if len(base) <= 2: - return False - return True - - -def _file_kind_label(display_doc: str) -> str: - """Return ``image`` or ``document`` based on filename extension.""" - name = (display_doc or "").strip().lower() - _, _, ext = name.rpartition(".") - if ext and f".{ext}" in _IMAGE_EXTENSIONS: - return "image" - return "document" - - -def _file_type_word(display_doc: str) -> str: - """Short type word for natural phrases (PDF, image, Word document, …).""" - name = (display_doc or "").strip().lower() - if "." not in name: - return _file_kind_label(display_doc) - ext = name.rsplit(".", 1)[-1] - if f".{ext}" in _IMAGE_EXTENSIONS: - return "image" - mapping = { - "pdf": "PDF", - "doc": "Word document", - "docx": "Word document", - "txt": "text file", - "rtf": "document", - "csv": "spreadsheet", - "xls": "spreadsheet", - "xlsx": "spreadsheet", - "ppt": "presentation", - "pptx": "presentation", - } - return mapping.get(ext, _file_kind_label(display_doc)) - - -def _friendly_file_phrase(display_doc: str) -> str: - """Natural file reference, e.g. ``your PDF`` or ``your document 'report.pdf'``. - - Machine / hash basenames from WhatsApp are not quoted. - """ - name = (display_doc or "").strip() - type_word = _file_type_word(name) - if ( - not name - or name.lower() in ("document", "your document", "uploaded_file", "image") - or _MACHINE_FILENAME_RE.match(name) - ): - return f"your {type_word}" - kind = _file_kind_label(name) - return f"your {kind} '{name}'" - - -def _format_search_excerpts(results: Any) -> str: - """Turn PageIndex search results into plain text for the LLM prompt.""" - if not results: - return "" - if isinstance(results, dict): - results = results.get("results") or results.get("documents") or [] - if not isinstance(results, list): - return str(results) - parts: List[str] = [] - for r in results: - if not isinstance(r, dict): - parts.append(str(r)) - continue - content = r.get("content") or r.get("text") or r.get("title") or "" - title = str(r.get("title") or "").strip() - if title and content: - parts.append(f"- [{title}] {content}") - elif content: - parts.append(f"- {content}") - return "\n".join(parts) - - async def _doc_description_lookup( agent: Any, ready_entries: List[Dict[str, Any]], @@ -245,6 +128,8 @@ async def _publish_whatsapp_message( display_doc: str, job_id: str, answered: bool = False, + internal_doc_name: str = "", + pending_question: str = "", ) -> bool: """Send a WhatsApp message directly via the WhatsApp API. @@ -302,6 +187,18 @@ async def _publish_whatsapp_message( if content and content.strip(): interaction.set_response(content.strip()) + if ( + answered + and (internal_doc_name or "").strip() + and (pending_question or "").strip() + ): + from .vault_events import answered_pending_event, record_vault_event + + await record_vault_event( + interaction, + answered_pending_event(internal_doc_name, pending_question), + ) + await interaction.save() whatsapp_action = await agent.get_action_by_type("WhatsAppAction") @@ -339,6 +236,8 @@ async def _publish_messenger_message( display_doc: str, job_id: str, answered: bool = False, + internal_doc_name: str = "", + pending_question: str = "", ) -> bool: """Send a Facebook Messenger message via the registered FacebookAction. @@ -420,6 +319,18 @@ async def _publish_messenger_message( if content and content.strip(): interaction.set_response(content.strip()) + if ( + answered + and (internal_doc_name or "").strip() + and (pending_question or "").strip() + ): + from .vault_events import answered_pending_event, record_vault_event + + await record_vault_event( + interaction, + answered_pending_event(internal_doc_name, pending_question), + ) + await interaction.save() # Use the already-registered live FacebookAction held by MessengerAdapter @@ -626,358 +537,6 @@ async def _download_and_import_graph( return effective_name -def _canned_ready_message( - display_doc: str, - doc_description: Optional[str] = None, - pending_question: Optional[str] = None, -) -> str: - """Fallback notification message (single message, never 'file'). - - When a pending question exists: ready → remind question → invite answer - follow-up (no LLM answer available in this fallback). - """ - type_word = _file_type_word(display_doc) - if _should_quote_filename(display_doc): - phrase = _friendly_file_phrase(display_doc) - lead = f"{phrase[0].upper()}{phrase[1:]} is ready" - else: - lead = f"Your {type_word} is ready" - - if pending_question: - msg = f"{lead}. You asked: {pending_question}." - if doc_description: - msg += f" It covers {doc_description}." - msg += " Ask me anything about it." - return msg - - if doc_description: - return f"{lead}. It covers {doc_description}. Ask me anything about it." - return f"{lead}. Ask me anything about it." - - -def _canned_ready_message_multi( - display_docs: List[str], - doc_descriptions: Optional[Dict[str, str]] = None, - pending_questions: Optional[Dict[str, str]] = None, -) -> str: - """Consolidated ready notice for multiple documents.""" - if not display_docs: - return "Your files are ready. Ask me anything about them." - if len(display_docs) == 1: - dd = (doc_descriptions or {}).get(display_docs[0]) if doc_descriptions else None - pq = ( - (pending_questions or {}).get(display_docs[0]) - if pending_questions - else None - ) - return _canned_ready_message( - display_docs[0], doc_description=dd, pending_question=pq - ) - - phrases: List[str] = [] - for d in display_docs: - if _should_quote_filename(d): - phrases.append(_friendly_file_phrase(d)) - else: - phrases.append(f"your {_file_type_word(d)}") - if len(phrases) == 2: - joined = f"{phrases[0]} and {phrases[1]}" - else: - joined = ", ".join(phrases[:-1]) + f", and {phrases[-1]}" - - lead = ( - f"{joined[0].upper()}{joined[1:]} {'are' if len(phrases) > 1 else 'is'} ready" - ) - - all_questions = [] - if pending_questions: - for d in display_docs: - pq = pending_questions.get(d) - if pq: - all_questions.append(pq) - - if all_questions: - msg = f"{lead}. You asked: {'; '.join(all_questions)}." - all_descs = [] - if doc_descriptions: - for d in display_docs: - desc = (doc_descriptions or {}).get(d) - if desc: - all_descs.append(desc) - if all_descs: - msg += f" They cover {'; '.join(all_descs)}." - msg += " Ask me anything about them." - return msg - - all_descs = [] - if doc_descriptions: - for d in display_docs: - desc = (doc_descriptions or {}).get(d) - if desc: - all_descs.append(desc) - if all_descs: - return f"{lead}. They cover {'; '.join(all_descs)}. Ask me anything about them." - return f"{lead}. Ask me anything about them." - - -async def _generate_ready_message( - *, - agent: Any, - vault_action: Any, - internal_doc_name: str, - display_doc: str, - utterance: str, - doc_description: Optional[str] = None, -) -> Optional[str]: - """One PageIndex search + one call_model for a single notification message. - - When the user had a pending question, the reply must: (1) say ready, - (2) remind them of the question, (3) answer from excerpts. Returns - generated text, or None on failure. - """ - page_index = await agent.get_action_by_type("PageIndexAction") - if page_index is None: - return None - - try: - results = await page_index.search( - query=utterance, - doc_name=internal_doc_name, - access_control=False, - ) - except Exception: - return None - - excerpts = _format_search_excerpts(results) - if not excerpts.strip(): - excerpts = "(no excerpts retrieved)" - - kind = _file_kind_label(display_doc) - type_word = _file_type_word(display_doc) - - name_guidance = ( - f"The filename is '{display_doc}'. Refer to the document using " - f"'{type_word}' (e.g. 'your {type_word}') unless the filename is " - f"clearly meaningful and descriptive — if it is a machine hash, a " - f"short generic name like 'edit' or 'file', or looks auto-generated, " - f"use the type word only and do not quote the filename." - ) - - has_question = bool((utterance or "").strip()) - system_parts = [ - "You write a single concise reply. Follow these rules exactly:", - f"- Briefly state that the {kind} is ready (e.g. 'Your {type_word} is ready'). {name_guidance} Never call it a 'file'.", - ] - if has_question: - system_parts.extend( - [ - "- Then remind the user of their pending question by quoting or " - "briefly paraphrasing it (e.g. 'You asked about …').", - "- Then answer that question using only the provided excerpts. " - "Keep the answer short — one or two sentences.", - "- Structure the message in that exact order: (1) ready notice, " - "(2) remind them of their question, (3) the answer.", - ] - ) - else: - system_parts.append( - "- The user did NOT ask a content question. Just say the document " - "is ready and invite them to ask. Do not invent an answer." - ) - system_parts.extend( - [ - "- Never invent facts. If the excerpts do not contain the answer, say so simply.", - "- No greetings, no corporate closers, no filler.", - ] - ) - if doc_description: - system_parts.append( - f"- The document description is: {doc_description}. You may briefly reference this." - ) - system_prompt = "\n".join(system_parts) - - user_parts = [ - f"Kind: {kind}", - f"Type word: {type_word}", - f"Filename: {display_doc}", - ] - if doc_description: - user_parts.append(f"Document description: {doc_description}") - if has_question: - user_parts.append(f"\nUser pending question: {utterance}") - user_parts.append( - f"\nSearch excerpts for doc_name={internal_doc_name!r}:\n{excerpts}" - ) - user_parts.append( - "\nWrite one short message: ready → remind question → answer." - ) - else: - user_parts.append("\nNo pending question. Write one short ready notice.") - user_prompt = "\n".join(user_parts) - - try: - from jvagent.action.utils.call_model import call_model - - text = await call_model(vault_action, user_prompt, system_prompt) - except Exception: - return None - - if not isinstance(text, str) or not text.strip(): - return None - return text.strip() - - -async def _generate_ready_message_multi( - *, - agent: Any, - vault_action: Any, - ready_entries: List[Dict[str, Any]], - doc_descriptions: Optional[Dict[str, str]] = None, -) -> Optional[str]: - """Generate a consolidated ready message for multiple documents. - - Searches each doc that has a pending_question, then builds a single - call_model prompt covering all docs. Falls back to None on failure. - """ - if not ready_entries: - return None - - display_docs: List[str] = [] - doc_kinds: List[str] = [] - search_parts: List[str] = [] - questions: List[str] = [] - - page_index = await agent.get_action_by_type("PageIndexAction") - - for entry in ready_entries: - internal = str(entry.get("internal_doc_name") or "").strip() - display = str(entry.get("display_doc") or "").strip() or "your document" - pq = str(entry.get("pending_question") or "").strip() - - display_docs.append(display) - doc_kinds.append(_file_kind_label(display)) - - if page_index is not None and pq and internal: - try: - results = await page_index.search( - query=pq, - doc_name=internal, - access_control=False, - ) - excerpts = _format_search_excerpts(results) - if not excerpts.strip(): - excerpts = "(no excerpts retrieved)" - except Exception: - excerpts = "(search failed)" - search_parts.append(f"doc_name={internal!r} ({display}):\n{excerpts}") - questions.append(f"- About {display}: {pq}") - - if not display_docs: - return None - - kinds_label = ( - "images" - if all(k == "image" for k in doc_kinds) - else ("documents" if all(k == "document" for k in doc_kinds) else "files") - ) - phrases: List[str] = [] - for d in display_docs: - phrases.append(f"your {_file_type_word(d)}") - if len(phrases) == 1: - joined = phrases[0] - is_plural = False - elif len(phrases) == 2: - joined = f"{phrases[0]} and {phrases[1]}" - is_plural = True - else: - joined = ", ".join(phrases[:-1]) + f", and {phrases[-1]}" - is_plural = True - - ready_line = ( - f"{joined[0].upper()}{joined[1:]} {'are' if is_plural else 'is'} ready." - ) - - filenames_line = ", ".join(repr(d) for d in display_docs) - system_parts = [ - "You write natural replies. Follow these rules exactly:", - f"- Always tell the user their {kinds_label} {'are' if is_plural else 'is'} ready. " - f"Refer to each document by its type word (e.g. 'your PDF', 'your image') " - f"unless the filename is clearly meaningful and descriptive — if a " - f"filename is a machine hash, a short generic name like 'edit' or 'file', " - f"or looks auto-generated, use the type word only and do not quote it. " - f"The filenames are: {filenames_line}. Never call them 'files'.", - ] - if questions: - system_parts.extend( - [ - "- Then remind the user of each pending question by quoting or " - "briefly paraphrasing it (e.g. 'You asked about …').", - "- Then answer each pending question using only the provided " - "excerpts. A few short sentences is fine.", - "- Structure the message in that exact order: (1) ready notice, " - "(2) remind them of their question(s), (3) the answer(s).", - ] - ) - else: - system_parts.append( - "- No pending questions. Just the ready notice and invite them to ask. " - "Do not invent answers from excerpts." - ) - if doc_descriptions: - desc_items = [ - f"{d}: {desc}" - for d, desc in doc_descriptions.items() - if desc and d in display_docs - ] - if desc_items: - system_parts.append( - "- Document descriptions: " - + "; ".join(desc_items) - + ". Briefly reference these when announcing readiness." - ) - system_parts.append("- Never invent facts.") - system_parts.append("- No greetings, no corporate or support-bot closers.") - system_prompt = "\n".join(system_parts) - - user_parts = [ - f"Ready {kinds_label}: {ready_line}", - ] - if doc_descriptions: - desc_lines = [ - f" {d}: {desc}" - for d, desc in doc_descriptions.items() - if desc and d in display_docs - ] - if desc_lines: - user_parts.append("\nDocument descriptions:") - user_parts.extend(desc_lines) - if questions: - user_parts.append("") - user_parts.append("Pending questions:") - user_parts.extend(questions) - user_parts.append("") - user_parts.append("Search excerpts:") - user_parts.extend(search_parts) - user_parts.append("") - user_parts.append("Write one message: ready → remind question(s) → answer(s).") - else: - user_parts.append("") - user_parts.append("No pending questions. Write one short ready notice.") - - user_prompt = "\n".join(user_parts) - - try: - from jvagent.action.utils.call_model import call_model - - text = await call_model(vault_action, user_prompt, system_prompt) - except Exception: - return None - - if not isinstance(text, str) or not text.strip(): - return None - return text.strip() - - @endpoint( "/artifact_handler_action/notify/{agent_id}", methods=["POST"], @@ -1283,6 +842,8 @@ async def _send_whatsapp_notifications( display_doc=display_doc, job_id=job_id, answered=answered, + internal_doc_name=internal_doc_name, + pending_question=pending_question, ) except Exception: logger.error( @@ -1376,6 +937,8 @@ async def _send_messenger_notifications( display_doc=display_doc, job_id=job_id, answered=answered, + internal_doc_name=internal_doc_name, + pending_question=pending_question, ) except Exception: logger.error( diff --git a/jvagent/action/artifact_handler_interact_action/ready_message.py b/jvagent/action/artifact_handler_interact_action/ready_message.py new file mode 100644 index 00000000..b7e35bba --- /dev/null +++ b/jvagent/action/artifact_handler_interact_action/ready_message.py @@ -0,0 +1,662 @@ +"""Ready-notice + pending-question answers from PageIndex document content. + +Shared by the WhatsApp/Messenger notify webhook (proactive push) and +``check_ingest_status`` (other-channel poll). Chunks first; search only +when chunks are empty. +""" + +from __future__ import annotations + +import re +from typing import Any, Dict, List, Optional, Tuple + +_IMAGE_EXTENSIONS = frozenset( + { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".heic", + ".heif", + ".bmp", + ".tif", + ".tiff", + } +) + +# WhatsApp / channel hash names like ``20260724_134410_c624a9f1.pdf``. +_MACHINE_FILENAME_RE = re.compile(r"^\d{8}_\d{6}_[0-9a-fA-F]{6,}(\.[A-Za-z0-9]+)?$") + +# Short or ambiguous filenames that should NOT be quoted in user-facing messages. +_GENERIC_NAMES = frozenset( + { + "edit", + "file", + "upload", + "document", + "image", + "photo", + "pic", + "img", + "scan", + "download", + "attachment", + "temp", + "tmp", + "test", + "untitled", + "new", + "copy", + "backup", + } +) + +_READY_DOC_TEXT_MAX_CHARS = 12000 + + +def _should_quote_filename(display_doc: str) -> bool: + """Decide whether to quote the filename in a user-facing message. + + Machine hash names and short/generic names are not quoted — the type word + (PDF, image, document, …) is used instead. + """ + name = (display_doc or "").strip() + if not name or _MACHINE_FILENAME_RE.match(name): + return False + base = name.rsplit(".", 1)[0] if "." in name else name + if base.lower() in _GENERIC_NAMES: + return False + if len(base) <= 2: + return False + return True + + +def _file_kind_label(display_doc: str) -> str: + """Return ``image`` or ``document`` based on filename extension.""" + name = (display_doc or "").strip().lower() + _, _, ext = name.rpartition(".") + if ext and f".{ext}" in _IMAGE_EXTENSIONS: + return "image" + return "document" + + +def _file_type_word(display_doc: str) -> str: + """Short type word for natural phrases (PDF, image, Word document, …).""" + name = (display_doc or "").strip().lower() + if "." not in name: + return _file_kind_label(display_doc) + ext = name.rsplit(".", 1)[-1] + if f".{ext}" in _IMAGE_EXTENSIONS: + return "image" + mapping = { + "pdf": "PDF", + "doc": "Word document", + "docx": "Word document", + "txt": "text file", + "rtf": "document", + "csv": "spreadsheet", + "xls": "spreadsheet", + "xlsx": "spreadsheet", + "ppt": "presentation", + "pptx": "presentation", + } + return mapping.get(ext, _file_kind_label(display_doc)) + + +def _friendly_file_phrase(display_doc: str) -> str: + """Natural file reference, e.g. ``your PDF`` or ``your document 'report.pdf'``. + + Machine / hash basenames from WhatsApp are not quoted. + """ + name = (display_doc or "").strip() + type_word = _file_type_word(name) + if ( + not name + or name.lower() in ("document", "your document", "uploaded_file", "image") + or _MACHINE_FILENAME_RE.match(name) + ): + return f"your {type_word}" + kind = _file_kind_label(name) + return f"your {kind} '{name}'" + + +def _format_content_parts(rows: Any) -> str: + """Turn PageIndex rows or chunks into plain text for the LLM prompt.""" + if not rows: + return "" + if isinstance(rows, dict): + rows = rows.get("results") or rows.get("documents") or rows.get("chunks") or [] + if not isinstance(rows, list): + return str(rows) + parts: List[str] = [] + for r in rows: + if not isinstance(r, dict): + parts.append(str(r)) + continue + content = str( + r.get("content") + or r.get("text") + or r.get("summary") + or r.get("title") + or "" + ).strip() + title = str(r.get("title") or "").strip() + if title and content and title != content: + parts.append(f"- [{title}] {content}") + elif content: + parts.append(f"- {content}") + return "\n".join(parts) + + +def _format_search_excerpts(results: Any) -> str: + """Turn PageIndex search results into plain text for the LLM prompt.""" + return _format_content_parts(results) + + +def _useful_source_text(text: str) -> bool: + """True when text can ground an answer (not empty / placeholder).""" + s = (text or "").strip() + if not s: + return False + lowered = s.lower() + if lowered in ("(no excerpts retrieved)", "(search failed)"): + return False + return True + + +def _truncate_ready_text(text: str, max_chars: int = _READY_DOC_TEXT_MAX_CHARS) -> str: + """Cap document text so a large PDF cannot blow the notify prompt.""" + s = (text or "").strip() + if max_chars <= 0 or len(s) <= max_chars: + return s + cut = s[:max_chars] + nl = cut.rfind("\n") + if nl >= max_chars // 2: + cut = cut[:nl] + return cut.rstrip() + "\n…" + + +def _empty_content_ready_message(display_doc: str, pending_question: str) -> str: + """Ready notice when PageIndex has no readable body for the pending question.""" + type_word = _file_type_word(display_doc) + if _should_quote_filename(display_doc): + phrase = _friendly_file_phrase(display_doc) + lead = f"{phrase[0].upper()}{phrase[1:]} is ready" + else: + lead = f"Your {type_word} is ready" + question = (pending_question or "").strip() + if question: + return f"{lead}. You asked: {question}. " "I couldn't read any content from it." + return f"{lead}. I couldn't read any content from it." + + +def _pageindex_collection(agent: Any, page_index: Any = None) -> str: + """Collection name for the ready document (typically agent id).""" + if page_index is not None: + try: + collection = str(page_index.resolve_collection() or "").strip() + if collection: + return collection + except Exception: + collection = str(getattr(page_index, "agent_id", "") or "").strip() + if collection: + return collection + return str(getattr(agent, "id", "") or "").strip() + + +async def _load_document_content( + agent: Any, + internal_doc_name: str, + page_index: Any = None, +) -> Tuple[str, bool]: + """Load PageIndex chunk text for a known doc_name. + + Returns ``(text, loaded)``. ``loaded`` is True when the chunk list call + succeeded (even if the body is empty). + """ + name = (internal_doc_name or "").strip() + if not name: + return "", False + try: + from jvagent.action.pageindex.documents import list_document_chunks + except Exception: + return "", False + collection = _pageindex_collection(agent, page_index) + if not collection: + return "", False + try: + out = await list_document_chunks(name, collection, per_page=0) + except Exception: + return "", False + text = _truncate_ready_text(_format_content_parts(out)) + return text, True + + +async def _search_document_content( + page_index: Any, + query: str, + internal_doc_name: str, +) -> Tuple[str, bool]: + """Scoped PageIndex search; supplement when chunk load is empty.""" + if page_index is None or not (internal_doc_name or "").strip(): + return "", False + q = (query or "").strip() or internal_doc_name + try: + results = await page_index.search( + query=q, + doc_name=internal_doc_name, + access_control=False, + ) + except Exception: + return "", False + text = _truncate_ready_text(_format_search_excerpts(results)) + return text, True + + +async def _ready_document_text( + agent: Any, + page_index: Any, + internal_doc_name: str, + query: str, +) -> Tuple[str, bool]: + """Document body for a ready-notify answer: chunks first, search if empty.""" + text, loaded = await _load_document_content(agent, internal_doc_name, page_index) + if _useful_source_text(text): + return text, True + search_text, searched = await _search_document_content( + page_index, query, internal_doc_name + ) + if _useful_source_text(search_text): + return search_text, True + return "", loaded or searched + + +def _canned_ready_message( + display_doc: str, + doc_description: Optional[str] = None, + pending_question: Optional[str] = None, +) -> str: + """Fallback notification message (single message, never 'file'). + + When a pending question exists: ready → remind question → invite answer + follow-up (no LLM answer available in this fallback). + """ + type_word = _file_type_word(display_doc) + if _should_quote_filename(display_doc): + phrase = _friendly_file_phrase(display_doc) + lead = f"{phrase[0].upper()}{phrase[1:]} is ready" + else: + lead = f"Your {type_word} is ready" + + if pending_question: + msg = f"{lead}. You asked: {pending_question}." + if doc_description: + msg += f" It covers {doc_description}." + msg += " Ask me anything about it." + return msg + + if doc_description: + return f"{lead}. It covers {doc_description}. Ask me anything about it." + return f"{lead}. Ask me anything about it." + + +def _canned_ready_message_multi( + display_docs: List[str], + doc_descriptions: Optional[Dict[str, str]] = None, + pending_questions: Optional[Dict[str, str]] = None, +) -> str: + """Consolidated ready notice for multiple documents.""" + if not display_docs: + return "Your files are ready. Ask me anything about them." + if len(display_docs) == 1: + dd = (doc_descriptions or {}).get(display_docs[0]) if doc_descriptions else None + pq = ( + (pending_questions or {}).get(display_docs[0]) + if pending_questions + else None + ) + return _canned_ready_message( + display_docs[0], doc_description=dd, pending_question=pq + ) + + phrases: List[str] = [] + for d in display_docs: + if _should_quote_filename(d): + phrases.append(_friendly_file_phrase(d)) + else: + phrases.append(f"your {_file_type_word(d)}") + if len(phrases) == 2: + joined = f"{phrases[0]} and {phrases[1]}" + else: + joined = ", ".join(phrases[:-1]) + f", and {phrases[-1]}" + + lead = ( + f"{joined[0].upper()}{joined[1:]} {'are' if len(phrases) > 1 else 'is'} ready" + ) + + all_questions = [] + if pending_questions: + for d in display_docs: + pq = pending_questions.get(d) + if pq: + all_questions.append(pq) + + if all_questions: + msg = f"{lead}. You asked: {'; '.join(all_questions)}." + all_descs = [] + if doc_descriptions: + for d in display_docs: + desc = (doc_descriptions or {}).get(d) + if desc: + all_descs.append(desc) + if all_descs: + msg += f" They cover {'; '.join(all_descs)}." + msg += " Ask me anything about them." + return msg + + all_descs = [] + if doc_descriptions: + for d in display_docs: + desc = (doc_descriptions or {}).get(d) + if desc: + all_descs.append(desc) + if all_descs: + return f"{lead}. They cover {'; '.join(all_descs)}. Ask me anything about them." + return f"{lead}. Ask me anything about them." + + +async def _generate_ready_message( + *, + agent: Any, + vault_action: Any, + internal_doc_name: str, + display_doc: str, + utterance: str, + doc_description: Optional[str] = None, +) -> Optional[str]: + """One ready notification: load the ingested document + call_model. + + When the user had a pending question, the reply must: (1) say ready, + (2) remind them of the question, (3) answer from PageIndex document + content (chunks first; search only if chunks are empty). Returns + generated text, or None on failure. + """ + kind = _file_kind_label(display_doc) + has_question = bool((utterance or "").strip()) + type_word = _file_type_word(display_doc) + + page_index = None + try: + page_index = await agent.get_action_by_type("PageIndexAction") + except Exception: + page_index = None + + source_text = "" + if has_question: + if not (internal_doc_name or "").strip(): + return None + source_text, reached_pageindex = await _ready_document_text( + agent, page_index, internal_doc_name, utterance + ) + if not _useful_source_text(source_text): + if reached_pageindex: + return _empty_content_ready_message(display_doc, utterance) + return None + elif page_index is None: + return None + + name_guidance = ( + f"The filename is '{display_doc}'. Refer to the document using " + f"'{type_word}' (e.g. 'your {type_word}') unless the filename is " + f"clearly meaningful and descriptive — if it is a machine hash, a " + f"short generic name like 'edit' or 'file', or looks auto-generated, " + f"use the type word only and do not quote the filename." + ) + + system_parts = [ + "You write a single concise reply. Follow these rules exactly:", + f"- Briefly state that the {kind} is ready (e.g. 'Your {type_word} is ready'). {name_guidance} Never call it a 'file'.", + ] + if has_question: + system_parts.extend( + [ + "- Then remind the user of their pending question by quoting or " + "briefly paraphrasing it (e.g. 'You asked about …').", + "- Then answer that question using the provided document content. " + "Keep the answer short — one or two sentences.", + "- Structure the message in that exact order: (1) ready notice, " + "(2) remind them of their question, (3) the answer.", + "- Never invent facts. Do not mention excerpts, search, or " + "processing internals.", + ] + ) + else: + system_parts.append( + "- The user did NOT ask a content question. Just say the document " + "is ready and invite them to ask. Do not invent an answer." + ) + system_parts.append("- Never invent facts.") + system_parts.append("- No greetings, no corporate closers, no filler.") + if doc_description: + system_parts.append( + f"- The document description is: {doc_description}. You may briefly reference this." + ) + system_prompt = "\n".join(system_parts) + + user_parts = [ + f"Kind: {kind}", + f"Type word: {type_word}", + f"Filename: {display_doc}", + ] + if doc_description: + user_parts.append(f"Document description: {doc_description}") + if has_question: + user_parts.append(f"\nUser pending question: {utterance}") + user_parts.append( + f"\nDocument content for doc_name={internal_doc_name!r}:\n{source_text}" + ) + user_parts.append( + "\nWrite one short message: ready → remind question → answer." + ) + else: + user_parts.append("\nNo pending question. Write one short ready notice.") + user_prompt = "\n".join(user_parts) + + try: + from jvagent.action.utils.call_model import call_model + + text = await call_model(vault_action, user_prompt, system_prompt) + except Exception: + return None + + if not isinstance(text, str) or not text.strip(): + return None + return text.strip() + + +async def _generate_ready_message_multi( + *, + agent: Any, + vault_action: Any, + ready_entries: List[Dict[str, Any]], + doc_descriptions: Optional[Dict[str, str]] = None, +) -> Optional[str]: + """Generate a consolidated ready message for multiple documents. + + Loads each ready doc's PageIndex chunks (search only if chunks are + empty). Falls back to None on failure. + """ + if not ready_entries: + return None + + display_docs: List[str] = [] + doc_kinds: List[str] = [] + content_parts: List[str] = [] + questions: List[str] = [] + any_content = False + any_reached = False + + page_index = None + try: + page_index = await agent.get_action_by_type("PageIndexAction") + except Exception: + page_index = None + + for entry in ready_entries: + internal = str(entry.get("internal_doc_name") or "").strip() + display = str(entry.get("display_doc") or "").strip() or "your document" + pq = str(entry.get("pending_question") or "").strip() + kind = _file_kind_label(display) + + display_docs.append(display) + doc_kinds.append(kind) + + if not pq: + continue + + questions.append(f"- About {display}: {pq}") + source_text = "" + reached = False + if internal: + source_text, reached = await _ready_document_text( + agent, page_index, internal, pq + ) + any_reached = any_reached or reached + if _useful_source_text(source_text): + any_content = True + content_parts.append(f"doc_name={internal!r} ({display}):\n{source_text}") + + if not display_docs: + return None + + if questions and not any_content: + if any_reached and len(display_docs) == 1: + return _empty_content_ready_message( + display_docs[0], + str(ready_entries[0].get("pending_question") or "").strip(), + ) + if any_reached: + asked = "; ".join( + str(e.get("pending_question") or "").strip() + for e in ready_entries + if str(e.get("pending_question") or "").strip() + ) + type_words = [f"your {_file_type_word(d)}" for d in display_docs] + if len(type_words) == 2: + joined = f"{type_words[0]} and {type_words[1]}" + else: + joined = ", ".join(type_words[:-1]) + f", and {type_words[-1]}" + lead = f"{joined[0].upper()}{joined[1:]} are ready" + if asked: + return f"{lead}. You asked: {asked}. I couldn't read any content from them." + return f"{lead}. I couldn't read any content from them." + return None + + kinds_label = ( + "images" + if all(k == "image" for k in doc_kinds) + else ("documents" if all(k == "document" for k in doc_kinds) else "files") + ) + phrases: List[str] = [] + for d in display_docs: + phrases.append(f"your {_file_type_word(d)}") + if len(phrases) == 1: + joined = phrases[0] + is_plural = False + elif len(phrases) == 2: + joined = f"{phrases[0]} and {phrases[1]}" + is_plural = True + else: + joined = ", ".join(phrases[:-1]) + f", and {phrases[-1]}" + is_plural = True + + ready_line = ( + f"{joined[0].upper()}{joined[1:]} {'are' if is_plural else 'is'} ready." + ) + + filenames_line = ", ".join(repr(d) for d in display_docs) + system_parts = [ + "You write natural replies. Follow these rules exactly:", + f"- Always tell the user their {kinds_label} {'are' if is_plural else 'is'} ready. " + f"Refer to each document by its type word (e.g. 'your PDF', 'your image') " + f"unless the filename is clearly meaningful and descriptive — if a " + f"filename is a machine hash, a short generic name like 'edit' or 'file', " + f"or looks auto-generated, use the type word only and do not quote it. " + f"The filenames are: {filenames_line}. Never call them 'files'.", + ] + if questions: + system_parts.extend( + [ + "- Then remind the user of each pending question by quoting or " + "briefly paraphrasing it (e.g. 'You asked about …').", + "- Then answer each pending question using the provided document " + "content. A few short sentences is fine.", + "- Structure the message in that exact order: (1) ready notice, " + "(2) remind them of their question(s), (3) the answer(s).", + ] + ) + facts_rule = ( + "- Never invent facts. Do not mention excerpts, search, or " + "processing internals." + ) + else: + system_parts.append( + "- No pending questions. Just the ready notice and invite them to ask. " + "Do not invent answers from document content." + ) + facts_rule = "- Never invent facts." + if doc_descriptions: + desc_items = [ + f"{d}: {desc}" + for d, desc in doc_descriptions.items() + if desc and d in display_docs + ] + if desc_items: + system_parts.append( + "- Document descriptions: " + + "; ".join(desc_items) + + ". Briefly reference these when announcing readiness." + ) + system_parts.append(facts_rule) + system_parts.append("- No greetings, no corporate or support-bot closers.") + system_prompt = "\n".join(system_parts) + + user_parts = [ + f"Ready {kinds_label}: {ready_line}", + ] + if doc_descriptions: + desc_lines = [ + f" {d}: {desc}" + for d, desc in doc_descriptions.items() + if desc and d in display_docs + ] + if desc_lines: + user_parts.append("\nDocument descriptions:") + user_parts.extend(desc_lines) + if questions: + user_parts.append("") + user_parts.append("Pending questions:") + user_parts.extend(questions) + if content_parts: + user_parts.append("") + user_parts.append("Document content:") + user_parts.extend(content_parts) + user_parts.append("") + user_parts.append("Write one message: ready → remind question(s) → answer(s).") + else: + user_parts.append("") + user_parts.append("No pending questions. Write one short ready notice.") + + user_prompt = "\n".join(user_parts) + + try: + from jvagent.action.utils.call_model import call_model + + text = await call_model(vault_action, user_prompt, system_prompt) + except Exception: + return None + + if not isinstance(text, str) or not text.strip(): + return None + return text.strip() diff --git a/jvagent/action/artifact_handler_interact_action/vault_events.py b/jvagent/action/artifact_handler_interact_action/vault_events.py new file mode 100644 index 00000000..f374e2a4 --- /dev/null +++ b/jvagent/action/artifact_handler_interact_action/vault_events.py @@ -0,0 +1,72 @@ +"""Compact vault history events for document save and pending-question answers. + +Written onto interactions so a later turn (with ``with_event``) can pin +``pageindex__search`` to the last vault ``doc_name``. +""" + +from __future__ import annotations + +import inspect +from typing import Any, Optional + +_VAULT_ACTION_NAME = "ArtifactHandlerInteractAction" + + +def saved_document_event( + doc_name: str, + *, + pending_question: Optional[str] = None, + status: str = "processing", +) -> str: + """One-line event when a document is accepted for ingest.""" + name = (doc_name or "").strip() + status_word = (status or "processing").strip() or "processing" + line = f"Saved document {name}. Status: {status_word}." + question = (pending_question or "").strip() + if question: + line += f" Pending question: {question}" + return line + + +def answered_pending_event(doc_name: str, pending_question: str) -> str: + """One-line event when a deferred question is answered from a ready doc.""" + name = (doc_name or "").strip() + question = (pending_question or "").strip() + return ( + f"Answered pending question on document {name}. " + "Use this doc_name for follow-ups about this image/document. " + f"Question: {question}" + ) + + +async def record_vault_event(target: Any, event: str) -> None: + """Append *event* on an interaction (or visitor.interaction) and save. + + No-ops on missing interaction or invalid event. Uses a fixed action name + so tool-dispatch turns still attribute the event to the vault action. + """ + text = (event or "").strip() + if not text or target is None: + return + interaction = target + adder = getattr(interaction, "add_event", None) + if not callable(adder): + interaction = getattr(target, "interaction", None) + adder = getattr(interaction, "add_event", None) if interaction else None + if interaction is None or not callable(adder): + return + try: + added = adder(text, _VAULT_ACTION_NAME) + except Exception: + return + if added is False: + return + saver = getattr(interaction, "save", None) + if not callable(saver): + return + try: + result = saver() + if inspect.isawaitable(result): + await result + except Exception: + pass diff --git a/jvagent/action/code_execution/code_execution_action.py b/jvagent/action/code_execution/code_execution_action.py index edd834e7..42279e52 100644 --- a/jvagent/action/code_execution/code_execution_action.py +++ b/jvagent/action/code_execution/code_execution_action.py @@ -35,6 +35,7 @@ provision_user_sandbox, resolve_agent_user, ) +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.tool_decorator import tool from jvagent.tooling.tool_executor import get_tool_visitor @@ -81,9 +82,15 @@ class CodeExecutionAction(Action): _executor: Optional[Executor] = None def executor(self) -> Executor: - """The execution backend. Override to swap in a container/jail backend.""" + """The execution backend. Isolation backend when runtime requires it.""" if self._executor is None: - self._executor = SubprocessExecutor() + inner: Executor = SubprocessExecutor() + from jvagent.harness.isolation import executor_for_backend + from jvagent.harness.runtime import get_runtime + + backend = get_runtime().isolation_backend + # Refuse — never fall back to subprocess — when a backend is named. + self._executor = executor_for_backend(backend, inner) return self._executor # -- per-user sandbox resolution -------------------------------------- @@ -115,20 +122,75 @@ async def resolve_user_cwd(self, visitor: Any) -> str: ) return cwd - async def stage_skill(self, visitor: Any, skill_dir: str, name: str) -> str: + async def stage_skill( + self, + visitor: Any, + skill_dir: str, + name: str, + *, + trust_tier: str = "trusted", + ) -> str: """Copy an activated skill folder into the user's slice (read-on-use). Returns the path *relative to the sandbox cwd* (e.g. ``staged_skills/pdf-generation``) so a script can be run as - ``python staged_skills/pdf-generation/scripts/x.py``. Idempotent per - turn: re-staging refreshes the copy. + ``python staged_skills/pdf-generation/scripts/x.py``. When a turn + snapshot is in cache the dest is snapshot/digest-keyed. Idempotent per + turn: re-staging refreshes the copy. Untrusted skills refuse unless the + runtime has an approved isolation backend. """ + from jvagent.scaffold.skill_resolve import skill_digest + cwd = await self.resolve_user_cwd(visitor) - rel = f"{STAGED_SKILLS_DIR}/{name}" - dest = os.path.join(cwd, *rel.split("/")) src = Path(skill_dir) if not src.is_dir(): raise FileNotFoundError(f"skill dir not found: {skill_dir}") + digest = skill_digest(src) + rel = f"{STAGED_SKILLS_DIR}/{name}" + snap = None + caller = None + try: + from jvagent.action.orchestrator.turn_cache import get_turn_cache + + turn = get_turn_cache() or {} + snap = turn.get("snapshot") + caller = turn.get("caller") + except Exception: + pass + if snap is not None and caller is not None: + from jvagent.harness.runtime import SkillManifest, get_runtime + + rt = get_runtime() + rt.require_usable(snap.snapshot_id) + if digest not in rt.store.skill_manifests: + spec = "claude" if (src / "scripts").is_dir() else "jv" + rt.register_manifest( + SkillManifest( + skill_key=name, + source="stage", + digest=digest, + declared_tools=(), + capabilities=(), + trust_tier=trust_tier, + spec=spec, + ) + ) + rt.activate_skill(caller, snap.snapshot_id, digest, trust_tier=trust_tier) + rel = f"{STAGED_SKILLS_DIR}/{snap.snapshot_id[:12]}/{digest}/{name}" + elif trust_tier == "untrusted": + from jvagent.harness.runtime import ( + APPROVED_ISOLATION_BACKENDS, + SkillIsolationRefused, + get_runtime, + ) + + backend = get_runtime().isolation_backend + if backend not in APPROVED_ISOLATION_BACKENDS: + raise SkillIsolationRefused( + "untrusted skill requires an approved isolation backend " + f"(got {backend!r}; subprocess is not a sandbox)" + ) + dest = os.path.join(cwd, *rel.split("/")) if os.path.exists(dest): shutil.rmtree(dest, ignore_errors=True) shutil.copytree(src, dest) @@ -146,7 +208,7 @@ async def get_tools(self) -> List[Any]: return collect_tools(self) - @tool(name="code_execution__bash") + @tool(name="code_execution__bash", idempotency_class=IdempotencyClass.NON_RETRYABLE) async def _t_bash( self, command: Annotated[str, "Shell command to run in the sandbox."], diff --git a/jvagent/action/file_interface/file_interface_action.py b/jvagent/action/file_interface/file_interface_action.py index abe7bb50..19461fb3 100644 --- a/jvagent/action/file_interface/file_interface_action.py +++ b/jvagent/action/file_interface/file_interface_action.py @@ -16,6 +16,7 @@ from jvagent.action.base import Action from jvagent.action.file_interface import _core +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.tool_decorator import tool from jvagent.tooling.tool_executor import get_tool_visitor @@ -71,7 +72,10 @@ async def _t_read_file( except Exception as e: return json.dumps({"ok": False, "error": f"{type(e).__name__}: {e}"}) - @tool(name="file_interface__write_file") + @tool( + name="file_interface__write_file", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_write_file( self, path: Annotated[str, "Relative path (e.g. output/notes.md)."], @@ -96,7 +100,10 @@ async def _t_write_file( except Exception as e: return json.dumps({"ok": False, "error": f"{type(e).__name__}: {e}"}) - @tool(name="file_interface__write_binary_file") + @tool( + name="file_interface__write_binary_file", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_write_binary_file( self, path: Annotated[str, "Relative path (e.g. output/report.pdf)."], @@ -138,7 +145,10 @@ async def _t_list_directory( except Exception as e: return json.dumps({"ok": False, "error": f"{type(e).__name__}: {e}"}) - @tool(name="file_interface__create_directory") + @tool( + name="file_interface__create_directory", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_create_directory( self, path: Annotated[str, "Relative directory path."], @@ -154,7 +164,10 @@ async def _t_create_directory( except Exception as e: return json.dumps({"ok": False, "error": f"{type(e).__name__}: {e}"}) - @tool(name="file_interface__delete_file") + @tool( + name="file_interface__delete_file", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_delete_file( self, path: Annotated[str, "Relative file path."], diff --git a/jvagent/action/google/google_calendar_action/google_calendar_action.py b/jvagent/action/google/google_calendar_action/google_calendar_action.py index 1fb54537..2c9d89c0 100644 --- a/jvagent/action/google/google_calendar_action/google_calendar_action.py +++ b/jvagent/action/google/google_calendar_action/google_calendar_action.py @@ -2,6 +2,7 @@ import logging from typing import Annotated, Any, ClassVar, Dict, List, Optional +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.tool_decorator import tool from ..google_action import GoogleAction @@ -85,7 +86,10 @@ async def _t_list_events( ) return json.dumps(results, indent=2) - @tool(name="calendar__create_event") + @tool( + name="calendar__create_event", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_create_event( self, summary: Annotated[str, "Event title/summary"], @@ -108,7 +112,10 @@ async def _t_create_event( ) return json.dumps(result, indent=2) - @tool(name="calendar__delete_event") + @tool( + name="calendar__delete_event", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_delete_event( self, calendar_id: Annotated[str, "Calendar identifier (default: 'primary')"], diff --git a/jvagent/action/google/google_drive_action/google_drive_action.py b/jvagent/action/google/google_drive_action/google_drive_action.py index 1c2d87ae..bfccbf88 100644 --- a/jvagent/action/google/google_drive_action/google_drive_action.py +++ b/jvagent/action/google/google_drive_action/google_drive_action.py @@ -5,6 +5,7 @@ from googleapiclient.http import MediaIoBaseDownload from jvspatial.env import env +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.tool_decorator import tool from ..google_action import GoogleAction @@ -423,7 +424,10 @@ async def _t_list_files( ) return json.dumps(results, indent=2) - @tool(name="google_drive__upload_file") + @tool( + name="google_drive__upload_file", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_upload_file( self, name: Annotated[str, "Name for the uploaded file."], @@ -494,7 +498,10 @@ async def _t_get_media( indent=2, ) - @tool(name="google_drive__share_file") + @tool( + name="google_drive__share_file", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_share_file( self, file_id: Annotated[str, "The ID of the file to share."], @@ -527,7 +534,10 @@ async def _t_share_file( ) return json.dumps(result, indent=2) - @tool(name="google_drive__delete_file") + @tool( + name="google_drive__delete_file", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_delete_file( self, file_id: Annotated[str, "The ID of the file to delete."], diff --git a/jvagent/action/google/google_gmail_action/google_gmail_action.py b/jvagent/action/google/google_gmail_action/google_gmail_action.py index aa5bd6e1..2d9a3c1e 100644 --- a/jvagent/action/google/google_gmail_action/google_gmail_action.py +++ b/jvagent/action/google/google_gmail_action/google_gmail_action.py @@ -7,6 +7,7 @@ standalone_mailbox_effective_sender_name, ) from jvagent.action.email_action.modules.gmail import GmailEmailProvider +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.tool_decorator import tool from ..google_action import GoogleAction @@ -90,7 +91,10 @@ async def mark_read(self, message_id: str, user_id: str = "me") -> Dict[str, Any .execute() ) - @tool(name="gmail__send_email") + @tool( + name="gmail__send_email", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_send_email( self, to: Annotated[str, "Recipient email address."], @@ -125,7 +129,10 @@ async def _t_get_message( await self.get_message(message_id, fmt=fmt or "full"), indent=2 ) - @tool(name="gmail__mark_read") + @tool( + name="gmail__mark_read", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_mark_read( self, message_id: Annotated[str, "ID of the message to mark as read."], diff --git a/jvagent/action/google/google_sheets_action/google_sheets_action.py b/jvagent/action/google/google_sheets_action/google_sheets_action.py index bdf4ce50..b2597e93 100644 --- a/jvagent/action/google/google_sheets_action/google_sheets_action.py +++ b/jvagent/action/google/google_sheets_action/google_sheets_action.py @@ -11,6 +11,7 @@ from googleapiclient.discovery import build from jvspatial.core.annotations import attribute +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.tool_decorator import tool from ..google_action import GoogleAction @@ -743,7 +744,10 @@ async def _t_read_spreadsheet( ) return json.dumps(result, indent=2) - @tool(name="google_sheets__update_spreadsheet") + @tool( + name="google_sheets__update_spreadsheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_update_spreadsheet( self, spreadsheet_url_or_id: Annotated[ @@ -776,7 +780,10 @@ async def _t_update_spreadsheet( ) return json.dumps(result, indent=2) - @tool(name="google_sheets__append_spreadsheet") + @tool( + name="google_sheets__append_spreadsheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_append_spreadsheet( self, spreadsheet_url_or_id: Annotated[ @@ -810,7 +817,10 @@ async def _t_append_spreadsheet( ) return json.dumps(result, indent=2) - @tool(name="google_sheets__create_spreadsheet") + @tool( + name="google_sheets__create_spreadsheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_create_spreadsheet( self, title: Annotated[str, "Title for the new spreadsheet"], @@ -819,7 +829,10 @@ async def _t_create_spreadsheet( result = await self.create_spreadsheet(title=title) return json.dumps(result, indent=2) - @tool(name="google_sheets__delete_spreadsheet") + @tool( + name="google_sheets__delete_spreadsheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_delete_spreadsheet( self, spreadsheet_url_or_id: Annotated[Optional[str], "Spreadsheet URL or ID"] = None, @@ -830,7 +843,10 @@ async def _t_delete_spreadsheet( ) return json.dumps({"deleted": result}, indent=2) - @tool(name="google_sheets__create_worksheet") + @tool( + name="google_sheets__create_worksheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_create_worksheet( self, title: Annotated[str, "Title for the new worksheet"], @@ -858,7 +874,10 @@ async def _t_create_worksheet( ) return json.dumps(result, indent=2) - @tool(name="google_sheets__update_worksheet") + @tool( + name="google_sheets__update_worksheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_update_worksheet( self, worksheet_title: Annotated[str, "Title of the worksheet to update"], @@ -887,7 +906,10 @@ async def _t_update_worksheet( ) return json.dumps(result, indent=2) - @tool(name="google_sheets__delete_worksheet") + @tool( + name="google_sheets__delete_worksheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_delete_worksheet( self, worksheet_title: Annotated[str, "Title of the worksheet to delete"], @@ -903,7 +925,10 @@ async def _t_delete_worksheet( ) return json.dumps(result, indent=2) - @tool(name="google_sheets__merge_cells") + @tool( + name="google_sheets__merge_cells", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_merge_cells( self, spreadsheet_url_or_id: Annotated[ @@ -932,7 +957,10 @@ async def _t_merge_cells( ) return json.dumps(result, indent=2) - @tool(name="google_sheets__unmerge_cells") + @tool( + name="google_sheets__unmerge_cells", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_unmerge_cells( self, spreadsheet_url_or_id: Annotated[ @@ -954,7 +982,10 @@ async def _t_unmerge_cells( ) return json.dumps(result, indent=2) - @tool(name="google_sheets__format_cells") + @tool( + name="google_sheets__format_cells", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_format_cells( self, spreadsheet_url_or_id: Annotated[ @@ -1010,7 +1041,10 @@ async def _t_last_filled_row( ) return json.dumps(result, indent=2) - @tool(name="google_sheets__batch_clear") + @tool( + name="google_sheets__batch_clear", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_batch_clear( self, spreadsheet_url_or_id: Annotated[ @@ -1032,7 +1066,10 @@ async def _t_batch_clear( ) return json.dumps(result, indent=2) - @tool(name="google_sheets__share_spreadsheet") + @tool( + name="google_sheets__share_spreadsheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_share_spreadsheet( self, spreadsheet_url_or_id: Annotated[ diff --git a/jvagent/action/interact/endpoints.py b/jvagent/action/interact/endpoints.py index 4f2db6b1..9aaeb1c1 100644 --- a/jvagent/action/interact/endpoints.py +++ b/jvagent/action/interact/endpoints.py @@ -613,6 +613,19 @@ async def interact_endpoint( client_ip = "unknown" # Check rate limit + if data: + from jvagent.harness.contracts import ( + HarnessContractError, + reject_host_domain_fields, + ) + + try: + reject_host_domain_fields(data) + except HarnessContractError as exc: + raise ValidationError( + message=str(exc), + details={"reason": "host_domain_forbidden"}, + ) from exc if not await rate_limiter.check_rate_limit(client_ip, agent_id): raise RateLimitError( message=f"Rate limit exceeded: {rate_limiter.rate_limit_per_minute} requests per minute", diff --git a/jvagent/action/interact/interact_walker.py b/jvagent/action/interact/interact_walker.py index 3d0961ab..7d7ea62d 100644 --- a/jvagent/action/interact/interact_walker.py +++ b/jvagent/action/interact/interact_walker.py @@ -107,6 +107,7 @@ class InteractWalker(Walker): background_actions: List["InteractAction"] = ( [] ) # Actions deferred for post-interaction execution + correlation_id: str = "" @property def tasks(self) -> "TaskStore": @@ -352,6 +353,33 @@ async def _bootstrap_interaction( self.conversation = conversation get_session_ms = (time.perf_counter() - t_session) * 1000 + try: + from jvagent.harness.contracts import NativeCaller + from jvagent.harness.runtime import SessionBusy, get_runtime + + rt = get_runtime() + memory_id = str(getattr(memory, "id", "") or "") + if memory_id and resolved_user_id: + rt.upsert_user(memory_id, resolved_user_id) + if memory_id and resolved_session_id: + rt.upsert_conversation(memory_id, resolved_session_id) + if resolved_session_id: + rt.acquire_session_lease(resolved_session_id) + caller = NativeCaller( + str(self.agent_id or getattr(here, "id", "") or ""), + str(resolved_user_id or ""), + str(resolved_session_id or ""), + ) + self.correlation_id = rt.new_correlation() + rt.record_span( + self.correlation_id, "session_admit", caller=caller.as_tuple() + ) + except SessionBusy as exc: + await self.report({"error": str(exc), "code": "session_busy"}) + return "session_resolution_error" + except Exception as exc: + logger.debug("harness session admit skipped: %s", exc) + access_control = await here.get_access_control_action() if ( access_control @@ -435,6 +463,25 @@ async def _bootstrap_create_interaction( session_id=self.session_id or "", ) set_interaction(self.interaction) + if self.correlation_id: + events = list(self.interaction.events or []) + events.append( + { + "action_name": "harness", + "content": f"correlation_id={self.correlation_id}", + } + ) + self.interaction.events = events + try: + await self.interaction.save() + except Exception: + pass + try: + from jvagent.harness.runtime import get_runtime + + get_runtime().bind_lease(self.session_id or "", self.correlation_id) + except Exception: + pass create_ms = (time.perf_counter() - t_create) * 1000 await self.report( { diff --git a/jvagent/action/interact/response_builder.py b/jvagent/action/interact/response_builder.py index 1c193960..327fbaf0 100644 --- a/jvagent/action/interact/response_builder.py +++ b/jvagent/action/interact/response_builder.py @@ -26,6 +26,23 @@ def _public_debug_hardened() -> bool: _TERMINAL_STATUSES = {"completed", "failed", "cancelled"} +# Journal blobs mixed into observability_metrics (HP-11). Recovery reads +# them off the Interaction node; export/debug lists must not treat them as +# model_call "Interaction" rows at the end of every turn. +_HARNESS_METRIC_PREFIX = "harness." + + +def export_observability_metrics(metrics: Any) -> List[Any]: + """Copy metrics, dropping harness journal/trace entries.""" + out: List[Any] = [] + for metric in metrics or []: + if isinstance(metric, dict): + kind = str(metric.get("kind") or "") + if kind.startswith(_HARNESS_METRIC_PREFIX): + continue + out.append(metric) + return out + def _parse_interaction_timestamp(value: Any) -> Optional[datetime]: """Parse datetime-like values from interaction/task payloads.""" @@ -169,7 +186,9 @@ def build_interaction_payload( "tasks": tasks if tasks is not None else [], "parameters": interaction.parameters, "events": interaction.events, - "observability_metrics": interaction.observability_metrics, + "observability_metrics": export_observability_metrics( + interaction.observability_metrics + ), "usage": getattr(interaction, "usage", None) or {}, "streamed": interaction.streamed, } diff --git a/jvagent/action/interact/webhook_pipeline.py b/jvagent/action/interact/webhook_pipeline.py index 00dc4467..8be45037 100644 --- a/jvagent/action/interact/webhook_pipeline.py +++ b/jvagent/action/interact/webhook_pipeline.py @@ -13,6 +13,7 @@ from jvspatial.exceptions import DatabaseError from jvagent.action.interact.conversation_lock_manager import ConversationLockManager +from jvagent.action.interact.response_builder import export_observability_metrics from jvagent.core.app import App from jvagent.logging.service import INTERACTION_LEVEL_NUMBER from jvagent.memory.conversation import Conversation @@ -219,10 +220,8 @@ def build_interaction_log_data( directives = interaction.directives if hasattr(interaction, "directives") else [] parameters = interaction.parameters if hasattr(interaction, "parameters") else [] events = interaction.events if hasattr(interaction, "events") else [] - observability_metrics = ( - interaction.observability_metrics - if hasattr(interaction, "observability_metrics") - else [] + observability_metrics = export_observability_metrics( + getattr(interaction, "observability_metrics", None) ) streamed = interaction.streamed if hasattr(interaction, "streamed") else False closed = interaction.closed if hasattr(interaction, "closed") else False @@ -240,6 +239,9 @@ def build_interaction_log_data( if hasattr(interaction, "get_state"): interaction_data = interaction.get_state() + if isinstance(interaction_data, dict): + interaction_data = dict(interaction_data) + interaction_data["observability_metrics"] = observability_metrics else: interaction_data = { "id": interaction_id, diff --git a/jvagent/action/microsoft/microsoft_excel_action/microsoft_excel_action.py b/jvagent/action/microsoft/microsoft_excel_action/microsoft_excel_action.py index 611f4fa9..8135f3c0 100644 --- a/jvagent/action/microsoft/microsoft_excel_action/microsoft_excel_action.py +++ b/jvagent/action/microsoft/microsoft_excel_action/microsoft_excel_action.py @@ -14,6 +14,7 @@ qualify_sheet_title, resolve_spreadsheet_id, ) +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.tool_decorator import tool from ..microsoft_action import MicrosoftAction @@ -390,7 +391,10 @@ async def _t_read_spreadsheet( ) return json.dumps(result, indent=2) - @tool(name="excel__update_spreadsheet") + @tool( + name="excel__update_spreadsheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_update_spreadsheet( self, spreadsheet_url_or_id: Annotated[Optional[str], "Spreadsheet URL or ID"] = None, @@ -418,7 +422,10 @@ async def _t_update_spreadsheet( ) return json.dumps(result, indent=2) - @tool(name="excel__append_spreadsheet") + @tool( + name="excel__append_spreadsheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_append_spreadsheet( self, spreadsheet_url_or_id: Annotated[Optional[str], "Spreadsheet URL or ID"] = None, @@ -449,7 +456,10 @@ async def _t_append_spreadsheet( ) return json.dumps(result, indent=2) - @tool(name="excel__create_spreadsheet") + @tool( + name="excel__create_spreadsheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_create_spreadsheet( self, title: Annotated[str, "Title for the new spreadsheet"], @@ -458,7 +468,10 @@ async def _t_create_spreadsheet( result = await self.create_spreadsheet(title=title) return json.dumps(result, indent=2) - @tool(name="excel__delete_spreadsheet") + @tool( + name="excel__delete_spreadsheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_delete_spreadsheet( self, spreadsheet_url_or_id: Annotated[ @@ -471,7 +484,10 @@ async def _t_delete_spreadsheet( ) return json.dumps({"deleted": result}, indent=2) - @tool(name="excel__create_worksheet") + @tool( + name="excel__create_worksheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_create_worksheet( self, title: Annotated[str, "Title for the new worksheet"], @@ -494,7 +510,10 @@ async def _t_create_worksheet( ) return json.dumps(result, indent=2) - @tool(name="excel__update_worksheet") + @tool( + name="excel__update_worksheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_update_worksheet( self, worksheet_title: Annotated[str, "Current title of the worksheet to update"], @@ -522,7 +541,10 @@ async def _t_update_worksheet( ) return json.dumps(result, indent=2) - @tool(name="excel__delete_worksheet") + @tool( + name="excel__delete_worksheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_delete_worksheet( self, worksheet_title: Annotated[str, "Title of the worksheet to delete"], @@ -535,7 +557,10 @@ async def _t_delete_worksheet( ) return json.dumps(result, indent=2) - @tool(name="excel__batch_clear") + @tool( + name="excel__batch_clear", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_batch_clear( self, spreadsheet_url_or_id: Annotated[Optional[str], "Spreadsheet URL or ID"] = None, @@ -554,7 +579,10 @@ async def _t_batch_clear( ) return json.dumps(result, indent=2) - @tool(name="excel__share_spreadsheet") + @tool( + name="excel__share_spreadsheet", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_share_spreadsheet( self, spreadsheet_url_or_id: Annotated[Optional[str], "Spreadsheet URL or ID"] = None, diff --git a/jvagent/action/microsoft/microsoft_onedrive_action/microsoft_onedrive_action.py b/jvagent/action/microsoft/microsoft_onedrive_action/microsoft_onedrive_action.py index d0ffafed..aacb4bb0 100644 --- a/jvagent/action/microsoft/microsoft_onedrive_action/microsoft_onedrive_action.py +++ b/jvagent/action/microsoft/microsoft_onedrive_action/microsoft_onedrive_action.py @@ -6,6 +6,7 @@ import httpx from jvspatial.env import env +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.tool_decorator import tool from ..microsoft_action import MicrosoftAction @@ -230,7 +231,10 @@ async def _t_list_files( ) return json.dumps(results, indent=2) - @tool(name="onedrive__upload_file") + @tool( + name="onedrive__upload_file", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_upload_file( self, name: Annotated[str, "Name for the uploaded file."], @@ -254,7 +258,10 @@ async def _t_upload_file( ) return json.dumps(result, indent=2) - @tool(name="onedrive__share_file") + @tool( + name="onedrive__share_file", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_share_file( self, file_id: Annotated[str, "The ID of the file to share."], @@ -284,7 +291,10 @@ async def _t_share_file( ) return json.dumps(result, indent=2) - @tool(name="onedrive__delete_file") + @tool( + name="onedrive__delete_file", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_delete_file( self, file_id: Annotated[str, "The ID of the file to delete."], diff --git a/jvagent/action/microsoft/microsoft_outlook_calendar_action/microsoft_outlook_calendar_action.py b/jvagent/action/microsoft/microsoft_outlook_calendar_action/microsoft_outlook_calendar_action.py index e8610609..52720b20 100644 --- a/jvagent/action/microsoft/microsoft_outlook_calendar_action/microsoft_outlook_calendar_action.py +++ b/jvagent/action/microsoft/microsoft_outlook_calendar_action/microsoft_outlook_calendar_action.py @@ -2,6 +2,7 @@ from typing import Annotated, Any, ClassVar, Dict, List, Optional from urllib.parse import quote +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.tool_decorator import tool from ..microsoft_action import MicrosoftAction @@ -122,7 +123,10 @@ async def _t_list_events( ) return json.dumps(results, indent=2) - @tool(name="outlook_calendar__create_event") + @tool( + name="outlook_calendar__create_event", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_create_event( self, summary: Annotated[str, "Event title/subject"], @@ -148,7 +152,10 @@ async def _t_create_event( ) return json.dumps(result, indent=2) - @tool(name="outlook_calendar__delete_event") + @tool( + name="outlook_calendar__delete_event", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_delete_event( self, calendar_id: Annotated[str, "Calendar identifier (default: 'primary')"], diff --git a/jvagent/action/microsoft/microsoft_outlook_mail_action/microsoft_outlook_mail_action.py b/jvagent/action/microsoft/microsoft_outlook_mail_action/microsoft_outlook_mail_action.py index dca4f76c..48f288ab 100644 --- a/jvagent/action/microsoft/microsoft_outlook_mail_action/microsoft_outlook_mail_action.py +++ b/jvagent/action/microsoft/microsoft_outlook_mail_action/microsoft_outlook_mail_action.py @@ -7,6 +7,7 @@ standalone_mailbox_effective_sender_name, ) from jvagent.action.email_action.modules.outlook import OutlookEmailProvider +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.tool_decorator import tool from ..microsoft_action import MicrosoftAction @@ -157,7 +158,10 @@ async def get_profile(self, user_id: str = "me") -> Dict[str, Any]: "displayName": me.get("displayName"), } - @tool(name="outlook__send_email") + @tool( + name="outlook__send_email", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_send_email( self, to: Annotated[str, "Recipient email address."], @@ -229,7 +233,10 @@ async def _t_get_message( user_id = user_id if user_id is not None else "me" return json.dumps(await self.get_message(message_id, user_id=user_id), indent=2) - @tool(name="outlook__mark_read") + @tool( + name="outlook__mark_read", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_mark_read( self, message_id: Annotated[str, "The ID of the message to mark as read."], diff --git a/jvagent/action/model/base.py b/jvagent/action/model/base.py index 0be26a17..69c3c9df 100644 --- a/jvagent/action/model/base.py +++ b/jvagent/action/model/base.py @@ -42,6 +42,37 @@ def _tool_definition_names(tools: Any) -> List[str]: return names +def _tool_names_fingerprint(names: Any) -> tuple: + if not isinstance(names, list): + return () + return tuple(n for n in names if isinstance(n, str) and n) + + +def _first_tick_for_tool_surface(interaction: Any, tool_names: List[str]) -> bool: + """True when this interaction has not yet stored ``tools`` for this name set. + + Debug replay needs the schemas once per tool surface. Later ticks of the + same names keep ``tool_names`` only so an agentic turn does not multiply + the same kilobytes by the tick count. + """ + names = _tool_names_fingerprint(tool_names) + if not names: + return False + events = getattr(interaction, "observability_metrics", None) or [] + for event in events: + if not isinstance(event, dict): + continue + event_type = event.get("event_type") + if event_type not in ("model_call", None, ""): + continue + ev_data = event.get("data") + if not isinstance(ev_data, dict) or "tools" not in ev_data: + continue + if _tool_names_fingerprint(ev_data.get("tool_names")) == names: + return False + return True + + T = TypeVar("T") @@ -158,12 +189,12 @@ class BaseModelAction(Action, ABC): telemetry_tool_definitions: bool = attribute( default=False, description=( - "Store the full tool/function definitions sent with each call on " - "the model_call observability event, so a debug UI can replay the " - "request exactly. Off by default: the schemas are identical on " - "every tick of a turn, so an agentic loop persists the same " - "kilobytes once per tick per interaction. Turn on per model action " - "while debugging. Tool NAMES are always recorded and cost nothing." + "Store the full tool/function definitions on every model_call " + "observability event. Off by default: the first tick of each unique " + "tool_names surface still records the schemas so a debug UI can " + "replay the request; later ticks of the same surface keep names " + "only. Turn this on per model action to persist schemas on every " + "tick. Tool NAMES are always recorded and cost nothing." ), ) @@ -628,9 +659,26 @@ async def _emit_observability( result_tools = getattr(result, "tools", None) if result_tools: data["tool_names"] = _tool_definition_names(result_tools) - if self.telemetry_tool_definitions: + if self.telemetry_tool_definitions or _first_tick_for_tool_surface( + interaction, data["tool_names"] + ): data["tools"] = result_tools + temperature = getattr(result, "temperature", None) + if isinstance(temperature, (int, float)) and not isinstance( + temperature, bool + ): + data["temperature"] = temperature + max_tokens = getattr(result, "max_tokens", None) + if isinstance(max_tokens, int) and not isinstance(max_tokens, bool): + data["max_tokens"] = max_tokens + tool_choice = getattr(result, "tool_choice", None) + if isinstance(tool_choice, (str, dict)): + data["tool_choice"] = tool_choice + parallel = getattr(result, "parallel_tool_calls", None) + if isinstance(parallel, bool): + data["parallel_tool_calls"] = parallel + # Build event and append directly to interaction event = { "event_type": event_type, diff --git a/jvagent/action/model/language/base.py b/jvagent/action/model/language/base.py index 95ca1f2f..81c22fe7 100644 --- a/jvagent/action/model/language/base.py +++ b/jvagent/action/model/language/base.py @@ -212,6 +212,10 @@ def __init__( self.thinking_tokens = thinking_tokens self.request_model = request_model self.tools = tools + self.temperature = None + self.max_tokens = None + self.tool_choice = None + self.parallel_tool_calls = None self._thinking_queue: Optional[asyncio.Queue] = thinking_queue self._thinking_closed: bool = False @@ -897,6 +901,10 @@ async def stream_with_retry() -> AsyncGenerator[str, None]: # (e.g. LiteLLM/OpenAI returns gpt-4.1-2025-04-14 for openai/gpt-4.1). result.request_model = kwargs.get("model") or getattr(self, "model", None) or "" result.tools = tools + result.temperature = kwargs.get("temperature") + result.max_tokens = kwargs.get("max_tokens") + result.tool_choice = kwargs.get("tool_choice") + result.parallel_tool_calls = kwargs.get("parallel_tool_calls") # Store calling_action_name in result for observability if calling_action_name: diff --git a/jvagent/action/model/resilience.py b/jvagent/action/model/resilience.py index 77a6dd38..c54b8534 100644 --- a/jvagent/action/model/resilience.py +++ b/jvagent/action/model/resilience.py @@ -140,6 +140,10 @@ def snapshot(self) -> Dict[str, Dict[str, Any]]: def reset(self) -> None: self._states.clear() + def bind_shared_backend(self, states: Dict[str, Any]) -> None: + """Optional shared breaker map (HP-07). Default remains process-local.""" + self._states = states + # Process-wide default breaker; the Orchestrator configures threshold/cooldown # on it from agent.yaml at each turn (cheap, idempotent). diff --git a/jvagent/action/orchestrator/catalog.py b/jvagent/action/orchestrator/catalog.py index 3699c400..ab112ff5 100644 --- a/jvagent/action/orchestrator/catalog.py +++ b/jvagent/action/orchestrator/catalog.py @@ -28,8 +28,9 @@ from jvagent.action.orchestrator.tools import SkillTool -# Per-agent assembled tool surface cache. Keyed by agent_id; invalidated on -# action reload when the orchestrator's config hash changes. +# Assembled tool surface cache. Keyed by snapshot_id + NativeCaller (HP-03). +# Generation invalidation drops matching keys; do not clear() the whole process +# dict per turn. @dataclass class _ToolSurfaceCacheEntry: config_hash: str @@ -42,7 +43,16 @@ class _ToolSurfaceCacheEntry: longtail: frozenset[str] = frozenset() -_TOOL_SURFACE_CACHE: Dict[str, _ToolSurfaceCacheEntry] = {} +_TOOL_SURFACE_CACHE: Dict[Tuple[str, str, str, str], _ToolSurfaceCacheEntry] = {} + + +def _surface_cache_key( + agent_id: str, + user_id: str = "", + session_id: str = "", + snapshot_id: str = "", +) -> Tuple[str, str, str, str]: + return (snapshot_id or "", agent_id, user_id or "", session_id or "") def compute_tool_surface_config_hash(orch: Any, enabled_action_ids: List[str]) -> str: @@ -70,20 +80,70 @@ def compute_tool_surface_config_hash(orch: Any, enabled_action_ids: List[str]) - return digest[:16] -def get_tool_surface_cache(agent_id: str) -> Optional[_ToolSurfaceCacheEntry]: - return _TOOL_SURFACE_CACHE.get(agent_id) +def get_tool_surface_cache( + agent_id: str, + user_id: str = "", + session_id: str = "", + snapshot_id: str = "", +) -> Optional[_ToolSurfaceCacheEntry]: + return _TOOL_SURFACE_CACHE.get( + _surface_cache_key(agent_id, user_id, session_id, snapshot_id) + ) -def set_tool_surface_cache(agent_id: str, entry: _ToolSurfaceCacheEntry) -> None: - _TOOL_SURFACE_CACHE[agent_id] = entry +def set_tool_surface_cache( + agent_id: str, + entry: _ToolSurfaceCacheEntry, + user_id: str = "", + session_id: str = "", + snapshot_id: str = "", +) -> None: + _TOOL_SURFACE_CACHE[ + _surface_cache_key(agent_id, user_id, session_id, snapshot_id) + ] = entry -def invalidate_tool_surface_cache(agent_id: Optional[str] = None) -> None: - """Drop cached tool surfaces for one agent or the entire process.""" - if agent_id is None: +def invalidate_tool_surface_cache( + agent_id: Optional[str] = None, snapshot_id: Optional[str] = None +) -> None: + """Drop cached surfaces for one agent, one snapshot, or the whole process.""" + if agent_id is None and snapshot_id is None: _TOOL_SURFACE_CACHE.clear() - else: - _TOOL_SURFACE_CACHE.pop(agent_id, None) + return + drop = [ + key + for key in _TOOL_SURFACE_CACHE + if (agent_id is not None and key[1] == agent_id) + or (snapshot_id is not None and key[0] == snapshot_id) + ] + for key in drop: + _TOOL_SURFACE_CACHE.pop(key, None) + + +def surface_cache_identity(visitor: Any = None) -> Dict[str, str]: + """NativeCaller + snapshot_id kwargs for the tool-surface cache.""" + user_id = str(getattr(visitor, "user_id", "") or "") if visitor is not None else "" + session_id = ( + str(getattr(visitor, "session_id", "") or "") if visitor is not None else "" + ) + snapshot_id = "" + try: + from jvagent.action.orchestrator.turn_cache import get_turn_cache + + turn = get_turn_cache() or {} + snap = turn.get("snapshot") + snapshot_id = str(getattr(snap, "snapshot_id", "") or "") + caller = turn.get("caller") + if caller is not None: + user_id = user_id or str(getattr(caller, "user_id", "") or "") + session_id = session_id or str(getattr(caller, "session_id", "") or "") + except Exception: + pass + return { + "user_id": user_id, + "session_id": session_id, + "snapshot_id": snapshot_id, + } # One-line summary length for a ``find_tool`` hit. Discovery only needs enough @@ -451,4 +511,5 @@ async def _use(args: Dict[str, Any]) -> str: "get_tool_surface_cache", "set_tool_surface_cache", "invalidate_tool_surface_cache", + "surface_cache_identity", ] diff --git a/jvagent/action/orchestrator/continuation.py b/jvagent/action/orchestrator/continuation.py index 475b2364..69c5afb3 100644 --- a/jvagent/action/orchestrator/continuation.py +++ b/jvagent/action/orchestrator/continuation.py @@ -16,7 +16,7 @@ from __future__ import annotations import logging -from typing import Any, FrozenSet, Optional, Set +from typing import Any, FrozenSet, Mapping, Optional, Set logger = logging.getLogger(__name__) @@ -495,6 +495,34 @@ async def cancel_orphan_flow_tasks( return cancelled +def completed_tool_observation( + tool_name: str, args: Optional[Mapping[str, Any]] = None +) -> Optional[str]: + """Cached IDEMPOTENT result for ``(tool_name, args)`` on the live TurnRun. + + Used on loop resume so a completed invocation is not dispatched again. + Returns ``None`` when there is no ledger hit (undeclared / non-idempotent + tools re-execute). + """ + try: + from jvagent.action.orchestrator.turn_cache import get_turn_cache + from jvagent.harness.runtime import get_runtime + except Exception: + return None + turn = get_turn_cache() or {} + corr = str(turn.get("correlation_id") or "") + if not corr: + return None + try: + return get_runtime().peek_completed_result( + correlation_id=corr, + tool_name=tool_name, + payload=dict(args or {}), + ) + except Exception: + return None + + __all__ = [ "active_flow_owner", "active_flow_note", @@ -511,4 +539,5 @@ async def cancel_orphan_flow_tasks( "task_lock_progress_count", "task_lock_title", "SOFT_ABANDON_ASK_STRIKE", + "completed_tool_observation", ] diff --git a/jvagent/action/orchestrator/egress.py b/jvagent/action/orchestrator/egress.py index 49980153..abdc0eb2 100644 --- a/jvagent/action/orchestrator/egress.py +++ b/jvagent/action/orchestrator/egress.py @@ -12,6 +12,27 @@ class OrchestratorEgressMixin: + @staticmethod + def _turn_delivered(interaction: Any) -> bool: + """True once user-facing content was delivered this turn. + + ``interaction.response`` alone is insufficient: streaming can latch + ``emitted`` before ``response`` is flushed, and ``commit_pending_adhoc`` + can set ``response`` without latching. Both signals must be checked so + ``_after_loop`` and ``_egress`` never double-send. + """ + if interaction is None: + return False + has_emitted = getattr(interaction, "has_emitted", None) + if callable(has_emitted): + try: + if has_emitted(): + return True + except Exception: + pass + response = getattr(interaction, "response", "") or "" + return isinstance(response, str) and bool(response.strip()) + @staticmethod def _ia_emitted(interaction: Any) -> bool: """True if a dispatched IA produced user-facing output this turn. @@ -41,11 +62,11 @@ async def _egress(self, visitor: "InteractWalker") -> None: double-sends. """ interaction = getattr(visitor, "interaction", None) - if interaction is None or interaction.has_emitted(): + if interaction is None or self._turn_delivered(interaction): return # Gather any directives a rails IA queued this turn (no model text to add). await self._send_reply(visitor) - if not interaction.has_emitted(): + if not self._turn_delivered(interaction): await self._send_reply(visitor, self.clarify_text) async def _send_reply( @@ -106,7 +127,7 @@ async def _send_reply( gathered = await gather(visitor) if gathered: return - if interaction is not None and interaction.has_emitted(): + if interaction is not None and self._turn_delivered(interaction): return except Exception as exc: logger.warning("orchestrator: responder.gather failed: %s", exc) diff --git a/jvagent/action/orchestrator/loop.py b/jvagent/action/orchestrator/loop.py index 1c4e0fdf..9d46eb8d 100644 --- a/jvagent/action/orchestrator/loop.py +++ b/jvagent/action/orchestrator/loop.py @@ -1443,7 +1443,10 @@ async def _dispatch_tool( ) tool_t0 = time.perf_counter() try: - if tool_call_timeout > 0: + cached = continuation.completed_tool_observation(tool_name, args) + if cached is not None: + obs = cached + elif tool_call_timeout > 0: obs = await asyncio.wait_for( tool.run(args), timeout=tool_call_timeout ) @@ -1688,7 +1691,7 @@ async def _after_loop(self, visitor: "InteractWalker", state: TurnState) -> None # tasks now; if one blocks on input it owns the egress. Inert until a # runner is registered, so skill-only turns are unaffected. interaction = getattr(visitor, "interaction", None) - emitted = bool(getattr(interaction, "response", "") if interaction else "") + emitted = self._turn_delivered(interaction) _stamp_observations(state.observations, state.last_obs_len, state.last_dec_meta) if state.ended_via == "model_error": # The model is unreachable: no finalize call (it would fail the same @@ -1714,7 +1717,7 @@ async def _after_loop(self, visitor: "InteractWalker", state: TurnState) -> None await self._send_reply(visitor, drain_directive, compose=True) state.ended_via = f"{state.ended_via}_drained" return - emitted = bool(getattr(interaction, "response", "") if interaction else "") + emitted = self._turn_delivered(interaction) # Budget/time ran out mid-task. Rather than dropping to the generic # clarify fallback (which discards the work and misreports the cause), diff --git a/jvagent/action/orchestrator/orchestrator_interact_action.py b/jvagent/action/orchestrator/orchestrator_interact_action.py index 17276fcf..8069ddcc 100644 --- a/jvagent/action/orchestrator/orchestrator_interact_action.py +++ b/jvagent/action/orchestrator/orchestrator_interact_action.py @@ -56,6 +56,7 @@ get_tool_surface_cache, invalidate_tool_surface_cache, set_tool_surface_cache, + surface_cache_identity, ) from jvagent.action.orchestrator.egress import OrchestratorEgressMixin from jvagent.action.orchestrator.loop import OrchestratorLoopMixin @@ -121,6 +122,7 @@ salvage_tool_call_text, truncate_thought, wrap_action_tool, + wrap_host_tool, ) from jvagent.action.orchestrator.turn_cache import ( bind_turn_cache, @@ -945,8 +947,75 @@ async def execute(self, visitor: "InteractWalker") -> None: interaction = getattr(visitor, "interaction", None) if interaction is None: return - with bind_turn_cache(): - await self._execute_turn(visitor) + with bind_turn_cache() as cache: + from jvagent.harness.contracts import NativeCaller, TurnRunState + from jvagent.harness.runtime import AdmissionRefused, get_runtime + + rt = get_runtime() + caller = NativeCaller( + str(getattr(visitor, "agent_id", "") or ""), + str(getattr(visitor, "user_id", "") or ""), + str(getattr(visitor, "session_id", "") or ""), + ) + if rt.is_draining: + logger.info("harness admission refused: draining") + return + cache["caller"] = caller + cache["interaction"] = interaction + cache["correlation_id"] = ( + getattr(visitor, "correlation_id", "") or rt.new_correlation() + ) + try: + cache["snapshot"] = rt.admit_snapshot(caller) + except AdmissionRefused: + logger.info("harness snapshot admission refused") + return + restored = None + payload = rt.checkpoint_from_interaction(interaction) + if payload: + restored = rt.import_checkpoint(payload) + if ( + restored is not None + and restored.state is TurnRunState.RECOVERY_REQUIRED + ): + await visitor.report( + { + "recovery_required": True, + "correlation_id": restored.correlation_id, + "reason": restored.reason, + } + ) + return + if restored is not None and restored.state not in ( + TurnRunState.COMPLETED, + TurnRunState.FAILED, + TurnRunState.CANCELLED, + ): + cache["correlation_id"] = restored.correlation_id + if restored.state is TurnRunState.WAITING_TOOL: + rt.transition( + restored.correlation_id, + TurnRunState.RUNNING, + reason="resume", + ) + else: + rt.start_turn( + cache["correlation_id"], + caller, + cache["snapshot"], + interaction_id=str(getattr(interaction, "id", "") or ""), + ) + try: + await self._execute_turn(visitor) + rt.complete_turn(cache["correlation_id"]) + except Exception: + rt.fail_turn(cache["correlation_id"], reason="execute_error") + raise + finally: + if getattr(visitor, "background_actions", None): + rt.mark_background(cache["correlation_id"]) + rt.persist_to_interaction(interaction, cache["correlation_id"]) + rt.prune_retention() async def _execute_turn(self, visitor: "InteractWalker") -> None: # Curate the remaining walk path: routable IAs (exposed as tools) must @@ -1139,7 +1208,9 @@ async def _assemble_tools( ) config_hash = compute_tool_surface_config_hash(self, action_ids) cached_surface = ( - get_tool_surface_cache(agent.id) if agent and agent.id else None + get_tool_surface_cache(agent.id, **surface_cache_identity(visitor)) + if agent and agent.id + else None ) use_tool_cache = ( cached_surface is not None @@ -1276,7 +1347,9 @@ async def _assemble_tools( if agent and agent.id: cache_entry.longtail = frozenset(longtail) - set_tool_surface_cache(agent.id, cache_entry) + set_tool_surface_cache( + agent.id, cache_entry, **surface_cache_identity(visitor) + ) if use_tool_cache and cached_surface is not None: longtail |= set(cached_surface.longtail) @@ -1354,6 +1427,19 @@ async def _egress_exec( visible.add("reply") visible.add("respond") + # Host capabilities are snapshot-bound tools, not descriptor metadata. + # Native tools win name collisions, and host tools remain discoverable + # through the standard lean catalogue. + snap = (get_turn_cache() or {}).get("snapshot") + if snap is not None: + for name in getattr(snap, "host_tool_names", ()) or (): + if not name or name in tools: + if name in tools: + logger.warning("host tool %r conflicts with native tool", name) + continue + tools[name] = wrap_host_tool(name) + longtail.add(name) + # Skill-only gating (ADR-0043), part 1 — the GLOB MATCH. It runs HERE, # before the lean policy, because a gated name must not win a lean # pre-surface slot only to be discarded again at install time: gating one @@ -1574,6 +1660,24 @@ async def _egress_exec( continue visible.discard(name) longtail.discard(name) + snap = None + try: + from jvagent.harness.runtime import get_runtime + + turn = get_turn_cache() or {} + snap = turn.get("snapshot") + if snap is not None: + get_runtime().update_snapshot_descriptors( + snap.snapshot_id, + native_tool_names=tuple(sorted(tools.keys())), + native_skill_keys=tuple( + getattr(d, "name", "") + for d in (skill_docs or []) + if getattr(d, "name", "") + ), + ) + except Exception as exc: + logger.debug("harness snapshot descriptor update skipped: %s", exc) return tools def _tool_surface_policy( diff --git a/jvagent/action/orchestrator/skill_providers.py b/jvagent/action/orchestrator/skill_providers.py index 41f72460..49513f62 100644 --- a/jvagent/action/orchestrator/skill_providers.py +++ b/jvagent/action/orchestrator/skill_providers.py @@ -1,10 +1,9 @@ -"""Host-provided SOP skills for embedded deployments (ADR-0012 extension). +"""Host-provided SOP skills for embedded deployments (ADR-0012 / HP-08). -Hosts (e.g. Integral) register sync callables that return additional -:class:`~jvagent.action.orchestrator.skills.SkillDoc` entries at runtime. -These merge into :func:`~jvagent.action.orchestrator.skills.discover_skill_docs` -after filesystem resolution. Filesystem / app-local skills win on name -collision so a host overlay cannot shadow the agent's base skill set. +Legacy process-global callables remain as a shim. New hosts should register +tools/skills on :class:`~jvagent.harness.runtime.HarnessRuntime` (per +``session_id``) and serve them through ``ToolSurfaceSnapshot``. The Orchestrator +must not import host services. """ from __future__ import annotations @@ -22,7 +21,7 @@ def register_host_skill_provider(fn: HostSkillProvider) -> None: - """Register a host skill provider. Safe to call multiple times.""" + """Register a legacy host skill provider. Prefer HostCapabilityProvider.""" if fn not in _providers: _providers.append(fn) @@ -33,9 +32,11 @@ def clear_host_skill_providers() -> None: def collect_host_skill_docs(agent: Any) -> List[SkillDoc]: - """Invoke every registered provider; best-effort per provider.""" - if not _providers: - return [] + """Invoke every registered provider; best-effort per provider. + + Snapshot-scoped host skills (HP-08) are merged from the admitted + ToolSurfaceSnapshot when a turn cache is bound. + """ docs: List[SkillDoc] = [] for provider in _providers: try: @@ -48,6 +49,37 @@ def collect_host_skill_docs(agent: Any) -> List[SkillDoc]: provider, exc, ) + try: + from jvagent.action.orchestrator.turn_cache import get_turn_cache + + turn = get_turn_cache() or {} + snap = turn.get("snapshot") + if snap is not None: + from jvagent.harness.runtime import get_runtime + + existing = {d.name for d in docs} + for key in getattr(snap, "host_skill_keys", ()) or (): + if key and key not in existing: + materialization = get_runtime().host_skill_materialization( + snap.caller.session_id, key + ) + if materialization is None: + logger.warning( + "host skill %r has no registered materialization", key + ) + continue + docs.append( + SkillDoc( + name=key, + description=f"Host skill {key}", + body=materialization.body, + source="host", + spec=materialization.spec, + digest=materialization.digest, + ) + ) + except Exception as exc: + logger.debug("orchestrator.skill_providers: snapshot merge failed: %s", exc) return docs diff --git a/jvagent/action/orchestrator/skill_tasks.py b/jvagent/action/orchestrator/skill_tasks.py index 4eeec1a2..6964bc6c 100644 --- a/jvagent/action/orchestrator/skill_tasks.py +++ b/jvagent/action/orchestrator/skill_tasks.py @@ -592,7 +592,14 @@ async def _activate(doc: Any) -> Optional[str]: directory = getattr(doc, "directory", "") or "" if directory: try: - rel = await code_exec.stage_skill(visitor, directory, doc.name) + trust = ( + "untrusted" + if getattr(doc, "spec", "jv") == "claude" + else "trusted" + ) + rel = await code_exec.stage_skill( + visitor, directory, doc.name, trust_tier=trust + ) notes.append( f"This skill's files are staged at '{rel}/' in your sandbox. Run " f"its scripts with the code_execution__bash tool — e.g. " diff --git a/jvagent/action/orchestrator/skills.py b/jvagent/action/orchestrator/skills.py index 1dae06a0..68390edd 100644 --- a/jvagent/action/orchestrator/skills.py +++ b/jvagent/action/orchestrator/skills.py @@ -37,6 +37,17 @@ def clear_skill_discovery_cache() -> None: _SKILL_DISCOVERY_CACHE.clear() +def _current_snapshot_id() -> str: + try: + from jvagent.action.orchestrator.turn_cache import get_turn_cache + + turn = get_turn_cache() or {} + snap = turn.get("snapshot") + return str(getattr(snap, "snapshot_id", "") or "") + except Exception: + return "" + + @dataclass(frozen=True) class SkillDoc: """A native SOP skill: a procedure that coordinates existing tools.""" @@ -71,6 +82,7 @@ class SkillDoc: allowed_channels: Tuple[str, ...] = () denied_channels: Tuple[str, ...] = () deny_access_directive: str = "" + digest: str = "" metadata: dict = field(default_factory=dict) @@ -141,6 +153,7 @@ def discover_skill_docs( repr(selector or "-all"), tuple(denied or ()), _skills_tree_mtime(str(app_root), str(namespace), str(name)), + _current_snapshot_id(), ) cached_docs = _SKILL_DISCOVERY_CACHE.get(cache_key) if cached_docs is not None: @@ -206,6 +219,7 @@ def discover_skill_docs( allowed_channels=tuple(bundle.get("allowed_channels") or ()), denied_channels=tuple(bundle.get("denied_channels") or ()), deny_access_directive=str(bundle.get("deny_access_directive") or ""), + digest=str(bundle.get("digest") or ""), metadata=bundle.get("metadata") or {}, ) ) diff --git a/jvagent/action/orchestrator/tools.py b/jvagent/action/orchestrator/tools.py index 2579705e..9dd97e74 100644 --- a/jvagent/action/orchestrator/tools.py +++ b/jvagent/action/orchestrator/tools.py @@ -88,19 +88,111 @@ def wrap_action_tool( ) async def _run(args: Dict[str, Any], _tool: Any = tool) -> str: + from jvagent.harness.contracts import ( + HarnessContractError, + IdempotencyClass, + reject_model_authority_fields, + ) + + call_args = dict(args or {}) + reject_model_authority_fields(call_args) if effective_access_label is not None and not await is_tool_allowed( agent, label=effective_access_label, user_id=user_id, channel=channel ): return "(access denied)" - call_kwargs = dict(args or {}) + call_kwargs = dict(call_args) if visitor is not None: call_kwargs["visitor"] = visitor + record = None + runtime = None + correlation_id = "" + interaction = None + snap = None + turn: Dict[str, Any] = {} + try: + from jvagent.action.orchestrator.turn_cache import get_turn_cache + from jvagent.harness.runtime import get_runtime + + turn = get_turn_cache() or {} + snap = turn.get("snapshot") + interaction = turn.get("interaction") + correlation_id = str(turn.get("correlation_id") or "") + if snap is not None and correlation_id: + runtime = get_runtime() + klass = getattr(_tool, "idempotency_class", None) + if not isinstance(klass, IdempotencyClass): + klass = None + record, cached = runtime.begin_invocation( + correlation_id=correlation_id, + snapshot_id=snap.snapshot_id, + tool_name=name, + payload=call_args, + idempotency_class=klass, + ) + if cached is not None: + return cached + except HarnessContractError as exc: + return f"(tool error: {exc})" + except Exception as exc: + logger.debug("wrap_action_tool: ledger skip: %s", exc) + record = None + runtime = None + if ( + snap is not None + and runtime is not None + and name in (getattr(snap, "host_tool_names", ()) or ()) + ): + from jvagent.harness.provider import provider_for + + try: + provider = turn.get("provider") or provider_for("native", runtime) + invoked = await provider.invoke( + snap.snapshot_id, + record.invocation_id if record is not None else "", + name, + call_args, + ) + content = json.dumps(dict(invoked.payload), default=str) + ok = bool(invoked.ok) + except Exception as exc: + logger.warning("wrap_action_tool: host tool %r raised: %s", name, exc) + content = f"(tool error: {exc})" + ok = False + if record is not None: + runtime.finish_invocation( + correlation_id=correlation_id, + record=record, + result=content, + ok=ok, + ) + if interaction is not None: + runtime.persist_to_interaction(interaction, correlation_id) + return content try: result = await _tool.call(**call_kwargs) except Exception as exc: logger.warning("wrap_action_tool: tool %r raised: %s", name, exc) + if runtime is not None and record is not None: + runtime.finish_invocation( + correlation_id=correlation_id, + record=record, + result=f"(tool error: {exc})", + ok=False, + ) + if interaction is not None: + runtime.persist_to_interaction(interaction, correlation_id) return f"(tool error: {exc})" - return (getattr(result, "content", "") or "") if result is not None else "" + content = (getattr(result, "content", "") or "") if result is not None else "" + if runtime is not None and record is not None: + runtime.finish_invocation( + correlation_id=correlation_id, + record=record, + result=content, + ok=True, + ) + if interaction is not None: + runtime.persist_to_interaction(interaction, correlation_id) + return content schema = getattr(tool, "parameters_schema", None) return SkillTool( @@ -114,6 +206,78 @@ async def _run(args: Dict[str, Any], _tool: Any = tool) -> str: ) +def wrap_host_tool(name: str, *, description: str = "") -> SkillTool: + """Adapt one snapshot-declared host capability into the model tool surface.""" + + async def _run(args: Dict[str, Any]) -> str: + from jvagent.action.orchestrator.turn_cache import get_turn_cache + from jvagent.harness.contracts import ( + HarnessContractError, + reject_model_authority_fields, + ) + from jvagent.harness.provider import provider_for + from jvagent.harness.runtime import get_runtime + + payload = dict(args or {}) + record = None + runtime = None + correlation_id = "" + interaction = None + try: + reject_model_authority_fields(payload) + turn = get_turn_cache() or {} + snap = turn.get("snapshot") + correlation_id = str(turn.get("correlation_id") or "") + interaction = turn.get("interaction") + if snap is None or not correlation_id: + raise HarnessContractError("host tool called outside an admitted turn") + runtime = get_runtime() + record, cached = runtime.begin_invocation( + correlation_id=correlation_id, + snapshot_id=snap.snapshot_id, + tool_name=name, + payload=payload, + ) + if cached is not None: + return cached + provider = turn.get("provider") or provider_for("native", runtime) + result = await provider.invoke( + snap.snapshot_id, record.invocation_id, name, payload + ) + content = json.dumps(dict(result.payload), default=str) + ok = bool(result.ok) + except HarnessContractError as exc: + if record is None: + return f"(tool error: {exc})" + content = f"(tool error: {exc})" + ok = False + except Exception as exc: + logger.warning("host tool %r raised: %s", name, exc) + content = f"(tool error: {exc})" + ok = False + if runtime is not None and record is not None: + runtime.finish_invocation( + correlation_id=correlation_id, + record=record, + result=content, + ok=ok, + ) + if interaction is not None: + runtime.persist_to_interaction(interaction, correlation_id) + return content + + return SkillTool( + name=name, + description=description or f"Host-provided capability: {name}", + run=_run, + parameters_schema={ + "type": "object", + "properties": {}, + "additionalProperties": True, + }, + ) + + def render_tools_section(tools: List[Any], *, lean: bool = False) -> str: """Render ``[{name, description}]`` (or objects) as a bulleted list. diff --git a/jvagent/action/pageindex/pageindex_action/pageindex_action.py b/jvagent/action/pageindex/pageindex_action/pageindex_action.py index 52751623..f7c24dcc 100644 --- a/jvagent/action/pageindex/pageindex_action/pageindex_action.py +++ b/jvagent/action/pageindex/pageindex_action/pageindex_action.py @@ -21,6 +21,7 @@ from jvagent.action.base import Action from jvagent.core.public_url import get_public_base_url from jvagent.env import get_jvagent_jvforge_base_url +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.tool_decorator import collect_tools, tool from .. import llm_bridge @@ -662,7 +663,10 @@ async def _t_search( # Agent prompt sees start_page/end_page; API/search rows keep index keys. return json.dumps(prompt_page_aliases(results), indent=2) - @tool(name="pageindex__assimilate") + @tool( + name="pageindex__assimilate", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_assimilate( self, doc: Annotated[ @@ -877,7 +881,10 @@ def _dump(docs: list) -> str: ) return payload - @tool(name="pageindex__delete") + @tool( + name="pageindex__delete", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_delete_doc( self, doc_name: Annotated[str, "Name of the document to delete."], diff --git a/jvagent/action/response/__init__.py b/jvagent/action/response/__init__.py index 799738b5..24036260 100644 --- a/jvagent/action/response/__init__.py +++ b/jvagent/action/response/__init__.py @@ -13,6 +13,7 @@ from jvagent.action.response.response_bus import ( ResponseBus, clear_agent_response_bus, + clear_interaction_egress, get_agent_response_bus, ) from jvagent.action.response.streaming import ( @@ -31,4 +32,5 @@ "stream_messages", "get_agent_response_bus", "clear_agent_response_bus", + "clear_interaction_egress", ] diff --git a/jvagent/action/response/response_bus.py b/jvagent/action/response/response_bus.py index 5a1d0775..90f02d40 100644 --- a/jvagent/action/response/response_bus.py +++ b/jvagent/action/response/response_bus.py @@ -80,6 +80,106 @@ def _governed(category: str, transient: bool) -> bool: _agent_bus_registry: Dict[str, "ResponseBus"] = {} +@dataclass +class InteractionEgressRecord: + """Process-wide single-egress claim for one interaction. + + ``Interaction.emitted`` is per-Python-object and ``_message_buffers`` is + per-ResponseBus. A rematerialized Interaction or a second bus instance + cannot see those latches, so Hello can still emit twice. This table is + the shared record every bus consults. + """ + + interaction_id: str + message_id: str = "" + session_id: str = "" + delivered: bool = False + finalized: bool = False + + +_interaction_egress: Dict[str, InteractionEgressRecord] = {} + + +def _interaction_egress_lock() -> asyncio.Lock: + from jvagent.core.async_locks import get_loop_lock + + return get_loop_lock("interaction_egress") + + +def clear_interaction_egress(interaction_id: Optional[str] = None) -> None: + """Test helper: drop one or all process-wide egress claims.""" + if interaction_id is None: + _interaction_egress.clear() + return + _interaction_egress.pop(str(interaction_id), None) + + +async def try_claim_user_egress( + interaction_id: str, + *, + message_id: str, + session_id: str = "", + continue_stream: bool = False, +) -> Tuple[bool, str]: + """Atomically claim the first user delivery for ``interaction_id``. + + Returns ``(allowed, canonical_message_id)``. Stream continuation of the + already-claimed identity is allowed; any other user delivery is not. + """ + key = str(interaction_id or "").strip() + if not key: + return True, message_id + async with _interaction_egress_lock(): + rec = _interaction_egress.get(key) + if rec is None: + _interaction_egress[key] = InteractionEgressRecord( + interaction_id=key, + message_id=message_id, + session_id=session_id, + delivered=True, + ) + return True, message_id + if continue_stream and rec.message_id and rec.message_id == message_id: + rec.delivered = True + return True, rec.message_id + if rec.delivered: + return False, rec.message_id or message_id + rec.delivered = True + rec.message_id = rec.message_id or message_id + rec.session_id = rec.session_id or session_id + return True, rec.message_id + + +async def try_claim_final( + interaction_id: str, + *, + message_id: Optional[str] = None, + session_id: str = "", +) -> Tuple[bool, str]: + """Atomically claim the single ``message_type=final`` for ``interaction_id``.""" + key = str(interaction_id or "").strip() + fallback = message_id or f"o.ResponseMessage.{uuid.uuid4().hex[:24]}" + if not key: + return True, fallback + async with _interaction_egress_lock(): + rec = _interaction_egress.get(key) + if rec is None: + rec = InteractionEgressRecord( + interaction_id=key, + message_id=fallback, + session_id=session_id, + ) + _interaction_egress[key] = rec + if rec.finalized: + return False, rec.message_id or fallback + rec.finalized = True + rec.delivered = True + rec.session_id = rec.session_id or session_id + if not rec.message_id: + rec.message_id = fallback + return True, rec.message_id + + def _agent_bus_lock() -> asyncio.Lock: from jvagent.core.async_locks import get_loop_lock @@ -103,6 +203,7 @@ def clear_agent_response_bus(agent_id: Optional[str] = None) -> None: """Test helper: drop one or all registry entries.""" if agent_id is None: _agent_bus_registry.clear() + clear_interaction_egress() return _agent_bus_registry.pop(str(agent_id), None) @@ -303,7 +404,28 @@ async def _enqueue_and_notify( ) -> None: """Add message to session queue, enforce bound, notify subscribers. Awaits async callbacks so SSE consumer receives messages before walk_task.done() check. + Durable outbox append happens before in-process fan-out (HP-06). """ + try: + from jvagent.action.orchestrator.turn_cache import get_turn_cache + from jvagent.harness.runtime import get_runtime + + turn = get_turn_cache() or {} + snap = turn.get("snapshot") + get_runtime().append_event( + session_id=session_id, + kind=getattr(message, "message_type", "") or "message", + message_id=str( + getattr(message, "id", None) + or getattr(message, "message_id", None) + or "" + ), + correlation_id=str(turn.get("correlation_id") or ""), + snapshot_id=str(getattr(snap, "snapshot_id", "") or ""), + payload=message.to_dict(), + ) + except Exception: + pass if session_id not in self._session_queues: self._session_queues[session_id] = [] queue = self._session_queues[session_id] @@ -443,13 +565,66 @@ async def _deliver_flush( if not (stream and not streaming_complete): content = scrub_text(content, _egress_parameters(interaction)) - if not stream: - # Non-streaming: immediate filters, adapter, accumulation, one adhoc message - message = ResponseMessage( + # ``Interaction.emitted`` is the framework's single-egress latch + # (ADR-0025). A live user stream may continue after its first chunk set + # the latch, but every separate non-transient user publish is rejected + # here at the delivery choke point. A non-stream publish while the + # accumulator is already open mints a new Object id; Integral splits + # bubbles on that id, so it is suppressed even if the first chunk has + # not latched yet (gate-held / empty). + # + # The process-wide InteractionEgressRecord is the latch that survives + # a rematerialized Interaction OR a second ResponseBus instance for + # the same agent (fresh-session Hello duplicates). + open_user_stream = bool( + interaction_id and interaction_id in self._adhoc_accumulation + ) + active_user_stream = bool(stream and open_user_stream) + has_emitted = getattr(interaction, "has_emitted", None) + already_emitted = False + if callable(has_emitted): + try: + already_emitted = has_emitted() is True + except Exception: + already_emitted = False + suppress_second = False + claimed_user_id = "" + if ( + message_category == "user" + and not transient + and interaction is not None + and (not stream and open_user_stream) + ): + suppress_second = True + elif ( + message_category == "user" and not transient and content and interaction_id + ): + if already_emitted and not active_user_stream: + suppress_second = True + else: + acc_id = "" + if active_user_stream: + acc_id = self._adhoc_accumulation[interaction_id].message_id + allowed, claimed_user_id = await try_claim_user_egress( + interaction_id, + message_id=acc_id or f"o.ResponseMessage.{uuid.uuid4().hex[:24]}", + session_id=session_id, + continue_stream=active_user_stream, + ) + if not allowed: + suppress_second = True + if hasattr(interaction, "mark_emitted"): + interaction.mark_emitted() + if suppress_second: + logger.debug( + "response bus: suppressed second user egress for interaction %s", + interaction_id or getattr(interaction, "id", ""), + ) + return ResponseMessage( session_id=session_id, user_id=user_id or "", interaction_id=interaction_id or "", - content=content, + content="", channel=channel, message_type="adhoc", metadata=metadata or {}, @@ -458,6 +633,25 @@ async def _deliver_flush( thought_type=thought_type, segment_id=message_segment_id, ) + + if not stream: + # Non-streaming: immediate filters, adapter, accumulation, one adhoc message + message_kwargs: Dict[str, Any] = { + "session_id": session_id, + "user_id": user_id or "", + "interaction_id": interaction_id or "", + "content": content, + "channel": channel, + "message_type": "adhoc", + "metadata": metadata or {}, + "timestamp": now, + "category": message_category, + "thought_type": thought_type, + "segment_id": message_segment_id, + } + if claimed_user_id: + message_kwargs["id"] = claimed_user_id + message = ResponseMessage(**message_kwargs) await _deliver_flush(message, content, transient) await self._enqueue_and_notify(message, session_id) if interaction_id: @@ -499,6 +693,8 @@ async def _deliver_flush( segment_id=message_segment_id, relay_to_adapters=relay_to_adapters, ) + if claimed_user_id and message_category == "user": + acc.message_id = claimed_user_id for chunk in chunk_text_by_lm_tokens(content): acc.chunks.append(chunk) acc.last_activity = time.time() @@ -563,8 +759,12 @@ async def _deliver_flush( thought_type=acc.thought_type, segment_id=acc.segment_id, ) - await self._enqueue_and_notify(final_message, session_id) - self._append_to_message_buffers(interaction_id, final_message) + await self._enqueue_claimed_final( + interaction_id=interaction_id, + session_id=session_id, + final_message=final_message, + category=message_category, + ) if message_category == "thought": self._thought_accumulation.pop( (interaction_id, acc.segment_id or "default"), None @@ -585,6 +785,8 @@ async def _deliver_flush( segment_id=message_segment_id, relay_to_adapters=relay_to_adapters, ) + if claimed_user_id and message_category == "user" and not acc.chunks: + acc.message_id = claimed_user_id # Incremental chunks are released through the accumulator's gate: it # withholds anything a later chunk could still change (a trailing # closer, an unfinished sentence) and returns only settled text. @@ -621,6 +823,14 @@ async def _deliver_flush( thought_type=thought_type, segment_id=message_segment_id, ) + # The first byte delivered owns this turn's user egress. + if ( + message_category == "user" + and not transient + and interaction is not None + and hasattr(interaction, "mark_emitted") + ): + interaction.mark_emitted() # Emit chunk to subscribers only chunk_message = ResponseMessage( id=acc.message_id, @@ -646,6 +856,13 @@ async def _deliver_flush( # a real chunk — a client that renders progressively from chunks # would otherwise never show the last sentence. if governed and content: + if ( + message_category == "user" + and not transient + and interaction is not None + and hasattr(interaction, "mark_emitted") + ): + interaction.mark_emitted() tail_meta = dict(metadata or {}) tail_meta["sequence"] = len(acc.chunks) tail_message = ResponseMessage( @@ -701,8 +918,12 @@ async def _deliver_flush( thought_type=acc.thought_type, segment_id=acc.segment_id, ) - await self._enqueue_and_notify(final_message, session_id) - self._append_to_message_buffers(interaction_id, final_message) + await self._enqueue_claimed_final( + interaction_id=interaction_id, + session_id=session_id, + final_message=final_message, + category=message_category, + ) if message_category == "thought": self._thought_accumulation.pop( (interaction_id, acc.segment_id or "default"), None @@ -764,8 +985,20 @@ async def commit_pending_adhoc( self._adhoc_accumulation.pop(interaction_id, None) return full_content = "".join(acc.chunks) + # Streaming publish() already flushed this turn to subscribers and + # interaction.response; commit_pending is a safety net for abandoned + # accumulators. Re-appending or re-emitting the same settled text + # duplicates bubbles downstream (integral message-boundary splits on a + # second adhoc id carrying the same prose). + current = (getattr(interaction, "response", "") or "") if interaction else "" + if full_content and current.strip() and full_content.strip() in current: + if interaction and hasattr(interaction, "mark_emitted"): + interaction.mark_emitted() + self._adhoc_accumulation.pop(interaction_id, None) + return now = await self._get_now() message = ResponseMessage( + id=acc.message_id, session_id=acc.session_id, user_id=acc.user_id or "", interaction_id=interaction_id, @@ -783,6 +1016,8 @@ async def commit_pending_adhoc( if self._can_send_to_adapter(adapter, message, relay_to_adapters=False): await self._send_to_adapter(adapter, message) if full_content and interaction: + if hasattr(interaction, "mark_emitted"): + interaction.mark_emitted() await self._append_to_interaction_response_impl( interaction=interaction, message_type="adhoc", @@ -842,6 +1077,42 @@ async def commit_pending_thoughts( ) self._thought_accumulation.pop(key, None) + async def _enqueue_claimed_final( + self, + *, + interaction_id: str, + session_id: str, + final_message: ResponseMessage, + category: str, + ) -> bool: + """Enqueue a stream-complete final if this interaction has not already finalized.""" + if category == "user": + allowed, canon = await try_claim_final( + interaction_id, + message_id=final_message.id, + session_id=session_id, + ) + if not allowed: + return False + if canon and canon != getattr(final_message, "id", ""): + final_message = ResponseMessage( + id=canon, + session_id=final_message.session_id, + user_id=final_message.user_id, + interaction_id=final_message.interaction_id, + content=final_message.content, + channel=final_message.channel, + message_type=final_message.message_type, + metadata=final_message.metadata or {}, + timestamp=final_message.timestamp, + category=final_message.category, + thought_type=final_message.thought_type, + segment_id=final_message.segment_id, + ) + await self._enqueue_and_notify(final_message, session_id) + self._append_to_message_buffers(interaction_id, final_message) + return True + async def _emit_final_signal( self, session_id: str, @@ -852,9 +1123,14 @@ async def _emit_final_signal( message_id: Optional[str] = None, ) -> None: """Internal: enqueue a final ResponseMessage and notify subscribers (no filters/adapters).""" + allowed, canon = await try_claim_final( + interaction_id, message_id=message_id, session_id=session_id + ) + if not allowed: + return now = await self._get_now() final_message = ResponseMessage( - id=message_id or f"o.ResponseMessage.{uuid.uuid4().hex[:24]}", + id=canon, session_id=session_id, user_id=user_id or "", interaction_id=interaction_id, @@ -1067,14 +1343,28 @@ async def finalize_interaction( # Token spend is computed in the endpoint after flush, when all model_call # events are present in observability_metrics. - # Emit final signal + # Streaming publish() already enqueued message_type=final under + # acc.message_id. A second final with a new Object id is a distinct + # assistant identity on the wire (Integral splits bubbles on that). + # try_claim_final is process-wide so a second ResponseBus cannot + # emit another one just because this instance's buffers are empty. user_id = getattr(interaction, "user_id", None) if interaction else None + last_user_id = None + for buffered in self._message_buffers.get(interaction_id) or []: + if (getattr(buffered, "category", "user") or "user") != "user": + continue + if buffered.id: + last_user_id = buffered.id + rec = _interaction_egress.get(interaction_id) + if rec is not None and rec.message_id: + last_user_id = rec.message_id await self._emit_final_signal( session_id=session_id, channel=channel, interaction_id=interaction_id, user_id=user_id, metadata={}, + message_id=last_user_id, ) # Clean up request-scoped resources (adhoc, message buffers) diff --git a/jvagent/action/response/streaming.py b/jvagent/action/response/streaming.py index 2bad64d1..93ae7ac5 100644 --- a/jvagent/action/response/streaming.py +++ b/jvagent/action/response/streaming.py @@ -12,6 +12,14 @@ def _sse_dedup_key(message: Any) -> tuple: """Dedup key for SSE replay overlap — (id, message_type, sequence).""" + if isinstance(message, dict): + mid = message.get("id") or message.get("message_id") or "" + mtype = message.get("message_type") or "" + meta = message.get("metadata") or {} + seq = meta.get("sequence") if isinstance(meta, dict) else None + if seq is None: + seq = message.get("content") or "" + return (mid, mtype, seq) mid = getattr(message, "id", None) or getattr(message, "message_id", None) or "" mtype = getattr(message, "message_type", "") or "" meta = getattr(message, "metadata", None) or {} @@ -40,6 +48,7 @@ async def stream_messages( interaction_id: Optional[str] = None, keepalive_seconds: Optional[float] = None, max_replay: Optional[int] = None, + cursor: Optional[str] = None, ) -> AsyncGenerator[str, None]: """Stream messages from response bus for a session. @@ -87,8 +96,39 @@ async def message_callback(message: Any) -> None: await response_bus.subscribe(session_id, message_callback, receive_chunks=True) try: - # Send any existing messages first, recording their ids for dedup. + # IDs emitted from durable replay must not also be emitted when an + # in-process queue still has the same response during reconnect. replayed_ids: set = set() + # Durable outbox replay (HP-06) when a cursor is supplied. Live bus + # backlog still covers in-process overlap; message ids remain the + # dedup key (test_streaming_dedup). + if cursor: + try: + from jvagent.harness.runtime import get_runtime + + for env in get_runtime().replay_from(session_id, cursor): + frame = dict(env.payload) + if not frame: + logger.warning( + "outbox event %s has no replayable payload", env.cursor + ) + continue + if not frame.get("id") and not frame.get("message_id"): + frame["id"] = env.message_id + if not frame.get("message_type"): + frame["message_type"] = env.kind + frame["harness"] = { + "sequence": env.sequence, + "cursor": env.cursor, + "correlation_id": env.correlation_id, + "snapshot_id": env.snapshot_id, + } + replayed_ids.add(_sse_dedup_key(frame)) + yield format_sse_chunk(frame) + except Exception as exc: + logger.debug("outbox cursor replay skipped: %s", exc) + + # Send any existing messages first, recording their ids for dedup. existing_messages = await response_bus.get_messages(session_id) if max_replay is not None and len(existing_messages) > max_replay: existing_messages = existing_messages[-max_replay:] diff --git a/jvagent/action/skill_hub/skill_hub_action.py b/jvagent/action/skill_hub/skill_hub_action.py index 6cbbad47..3f74180c 100644 --- a/jvagent/action/skill_hub/skill_hub_action.py +++ b/jvagent/action/skill_hub/skill_hub_action.py @@ -32,6 +32,7 @@ run_skills_list, ) from jvagent.core.app_context import get_app_root +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.tool_decorator import tool logger = logging.getLogger(__name__) @@ -419,7 +420,10 @@ async def _t_search_registry( result = await self.search_registry(arguments, visitor=visitor) return result if isinstance(result, str) else json.dumps(result) - @tool(name="skill_hub__install_skill") + @tool( + name="skill_hub__install_skill", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_install_skill( self, source: Annotated[ @@ -460,7 +464,10 @@ async def _t_list_installed(self) -> str: result = await self.list_installed(arguments, visitor=visitor) return result if isinstance(result, str) else json.dumps(result) - @tool(name="skill_hub__remove_skill") + @tool( + name="skill_hub__remove_skill", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def _t_remove_skill( self, skill_name: Annotated[str, "Name of the installed skill to remove"], diff --git a/jvagent/action/whatsapp/whatsapp_action.py b/jvagent/action/whatsapp/whatsapp_action.py index 629e7bb8..8ab87163 100644 --- a/jvagent/action/whatsapp/whatsapp_action.py +++ b/jvagent/action/whatsapp/whatsapp_action.py @@ -15,6 +15,7 @@ from jvagent.action.base import Action from jvagent.core.public_url import get_public_base_url +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.tool_decorator import tool from jvagent.tooling.tool_executor import get_dispatch_context, get_tool_visitor @@ -1587,7 +1588,10 @@ async def list_templates(self) -> str: logger.exception("whatsapp__list_templates failed") return json.dumps({"ok": False, "error": f"{type(e).__name__}: {e}"}) - @tool(name="whatsapp__send_template") + @tool( + name="whatsapp__send_template", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def send_template( self, template_name: Annotated[ @@ -1804,7 +1808,10 @@ async def list_flows(self) -> str: logger.exception("whatsapp__list_flows failed") return json.dumps({"ok": False, "error": f"{type(e).__name__}: {e}"}) - @tool(name="whatsapp__send_flow") + @tool( + name="whatsapp__send_flow", + idempotency_class=IdempotencyClass.NON_RETRYABLE, + ) async def send_flow( self, flow_id: Annotated[ diff --git a/jvagent/core/distributed_lease.py b/jvagent/core/distributed_lease.py index 609e65f7..be9a78df 100644 --- a/jvagent/core/distributed_lease.py +++ b/jvagent/core/distributed_lease.py @@ -10,7 +10,9 @@ Backends are the SAME ones the conversation turn-lock uses (one Redis/DynamoDB config per deployment). Without either configured this falls back to an in-process lock, which only serializes within a single worker — cross-process -protection genuinely requires Redis/DynamoDB. +protection genuinely requires Redis/DynamoDB. Session/turn ownership leases live +on :class:`jvagent.harness.runtime.HarnessRuntime` (HP-07); this module is the +bootstrap/identity mutex, not a silent stand-in for distributed session ownership. """ from __future__ import annotations diff --git a/jvagent/embed/interact.py b/jvagent/embed/interact.py index 7b63ccb4..fde5ecac 100644 --- a/jvagent/embed/interact.py +++ b/jvagent/embed/interact.py @@ -47,15 +47,48 @@ def cancel_interact( session_id: Optional[str] = None, thread_id: Optional[str] = None, ) -> bool: - """Cancel an in-flight :func:`interact_stream` walker task, if any.""" + """Cancel an in-flight :func:`interact_stream` walker task, if any. + + Task handle cancel is the delivery interrupt. TurnRun is the source of + truth: the matching session journal is marked ``recovery_required``. + """ + cancelled = False for key in (thread_id, session_id): if not key: continue task = _interact_tasks.get(key) if task is not None and not task.done(): task.cancel() - return True - return False + cancelled = True + if cancelled: + _mark_embed_recovery(session_id=session_id, thread_id=thread_id) + return cancelled + + +def _mark_embed_recovery( + *, + session_id: Optional[str] = None, + thread_id: Optional[str] = None, +) -> None: + try: + from jvagent.harness.runtime import get_runtime + + rt = get_runtime() + for sid in (session_id, thread_id): + if not sid: + continue + corr = rt.correlation_for_session(sid) + if not corr: + continue + try: + rt.mark_recovery(corr, reason="embed_cancel") + except Exception: + logger.debug( + "embed.cancel_interact: mark_recovery failed corr=%s", corr + ) + break + except Exception: + logger.debug("embed.cancel_interact: harness recovery skip", exc_info=True) async def interact( @@ -116,6 +149,20 @@ async def interact( details={"utterance": utterance}, ) + if data: + from jvagent.harness.contracts import ( + HarnessContractError, + reject_host_domain_fields, + ) + + try: + reject_host_domain_fields(data) + except HarnessContractError as exc: + raise ValidationError( + message=str(exc), + details={"reason": "host_domain_forbidden"}, + ) from exc + # Imports kept lazy so `import jvagent.embed` stays cheap and works even # in environments that haven't called `bootstrap()` yet. from jvspatial import flush_deferred_entities @@ -466,6 +513,14 @@ async def _disc() -> bool: if sid not in task_keys: task_keys.append(sid) await _register_interact_task(sid, walk_task) + try: + from jvagent.harness.runtime import get_runtime + + corr = getattr(walker, "correlation_id", "") or "" + if corr: + get_runtime().bind_lease(sid, corr) + except Exception: + logger.debug("embed.interact_stream: lease bind skip", exc_info=True) # Stream messages off the response bus until the walker finishes. if walker.response_bus and walker.session_id: diff --git a/jvagent/harness/__init__.py b/jvagent/harness/__init__.py new file mode 100644 index 00000000..3cb0e37d --- /dev/null +++ b/jvagent/harness/__init__.py @@ -0,0 +1,49 @@ +"""Public harness contract types and runtime (ADR-0054).""" + +from jvagent.harness.contracts import ( + CONTRACT_VERSION, + EventEnvelope, + HarnessContractError, + HostCapabilityProvider, + IdempotencyClass, + InvocationRecord, + NativeCaller, + SkillMaterialization, + SnapshotSelector, + ToolResult, + ToolSurfaceSnapshot, + TurnRunState, + assert_turn_run_transition, + native_caller_from_mapping, + reject_host_domain_fields, + reject_model_authority_fields, +) +from jvagent.harness.runtime import ( + HarnessRuntime, + HarnessStore, + get_runtime, + reset_runtime, +) + +__all__ = [ + "CONTRACT_VERSION", + "EventEnvelope", + "HarnessContractError", + "HarnessRuntime", + "HarnessStore", + "HostCapabilityProvider", + "IdempotencyClass", + "InvocationRecord", + "NativeCaller", + "SkillMaterialization", + "SnapshotSelector", + "ToolResult", + "ToolSurfaceSnapshot", + "TurnRunState", + "assert_turn_run_transition", + "get_runtime", + "native_caller_from_mapping", + "reject_host_domain_fields", + "reject_model_authority_fields", + "reset_runtime", +] diff --git a/jvagent/harness/contracts.py b/jvagent/harness/contracts.py new file mode 100644 index 00000000..f3a99da9 --- /dev/null +++ b/jvagent/harness/contracts.py @@ -0,0 +1,283 @@ +"""Host-neutral harness contracts (ADR-0054). + +Types and validators only. No Orchestrator I/O, persistence, or host imports. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Mapping, Optional, Protocol, Tuple + +CONTRACT_VERSION = "1.0.0" + +FORBIDDEN_HOST_DOMAIN_KEYS = frozenset( + { + "workspace_id", + "organization", + "organization_id", + "org_id", + "content_profile_id", + } +) + +FORBIDDEN_AUTHORITY_KEYS = frozenset( + { + "authority", + "trust_tier", + "capability_token", + "snapshot_secret", + "isolation_backend", + } +) + +_NATIVE_CALLER_KEYS = frozenset({"agent_id", "user_id", "session_id"}) + + +class HarnessContractError(ValueError): + """Invalid harness identity, transition, snapshot, or payload.""" + + +class TurnRunState(str, Enum): + ACCEPTED = "accepted" + RUNNING = "running" + WAITING_TOOL = "waiting_tool" + WAITING_APPROVAL = "waiting_approval" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + RECOVERY_REQUIRED = "recovery_required" + + +class IdempotencyClass(str, Enum): + IDEMPOTENT = "idempotent" + COMPENSATABLE = "compensatable" + NON_RETRYABLE = "non_retryable" + + +TURN_RUN_TERMINAL = frozenset( + { + TurnRunState.COMPLETED, + TurnRunState.FAILED, + TurnRunState.CANCELLED, + TurnRunState.RECOVERY_REQUIRED, + } +) + +LEGAL_TURN_RUN_TRANSITIONS: Mapping[TurnRunState, frozenset[TurnRunState]] = { + TurnRunState.ACCEPTED: frozenset( + { + TurnRunState.RUNNING, + TurnRunState.CANCELLED, + TurnRunState.FAILED, + } + ), + TurnRunState.RUNNING: frozenset( + { + TurnRunState.WAITING_TOOL, + TurnRunState.WAITING_APPROVAL, + TurnRunState.COMPLETED, + TurnRunState.FAILED, + TurnRunState.CANCELLED, + TurnRunState.RECOVERY_REQUIRED, + } + ), + TurnRunState.WAITING_TOOL: frozenset( + { + TurnRunState.RUNNING, + TurnRunState.FAILED, + TurnRunState.CANCELLED, + TurnRunState.RECOVERY_REQUIRED, + } + ), + TurnRunState.WAITING_APPROVAL: frozenset( + { + TurnRunState.RUNNING, + TurnRunState.FAILED, + TurnRunState.CANCELLED, + TurnRunState.RECOVERY_REQUIRED, + } + ), +} + + +def reject_host_domain_fields(data: Mapping[str, Any]) -> None: + leaked = FORBIDDEN_HOST_DOMAIN_KEYS.intersection(data) + if leaked: + raise HarnessContractError( + f"host-domain field(s) forbidden on harness types: {sorted(leaked)}" + ) + + +def reject_model_authority_fields(payload: Mapping[str, Any]) -> None: + leaked = FORBIDDEN_AUTHORITY_KEYS.intersection(payload) + if leaked: + raise HarnessContractError( + f"authority field(s) forbidden on model-generated payloads: " + f"{sorted(leaked)}" + ) + + +def assert_turn_run_transition(src: TurnRunState, dst: TurnRunState) -> None: + allowed = LEGAL_TURN_RUN_TRANSITIONS.get(src, frozenset()) + if dst not in allowed: + raise HarnessContractError( + f"illegal TurnRun transition {src.value} -> {dst.value}" + ) + + +@dataclass(frozen=True) +class NativeCaller: + """Admission identity. Host scopes map to session_id outside jvagent.""" + + agent_id: str + user_id: str + session_id: str + + def as_tuple(self) -> Tuple[str, str, str]: + return (self.agent_id, self.user_id, self.session_id) + + def to_mapping(self) -> dict[str, str]: + return { + "agent_id": self.agent_id, + "user_id": self.user_id, + "session_id": self.session_id, + } + + +def native_caller_from_mapping(data: Mapping[str, Any]) -> NativeCaller: + reject_host_domain_fields(data) + unexpected = set(data) - _NATIVE_CALLER_KEYS + if unexpected: + raise HarnessContractError( + f"unexpected NativeCaller field(s): {sorted(unexpected)}" + ) + missing = _NATIVE_CALLER_KEYS - set(data) + if missing: + raise HarnessContractError(f"missing NativeCaller field(s): {sorted(missing)}") + return NativeCaller( + agent_id=str(data["agent_id"]), + user_id=str(data["user_id"]), + session_id=str(data["session_id"]), + ) + + +@dataclass(frozen=True) +class ToolSurfaceSnapshot: + snapshot_id: str + caller: NativeCaller + native_tool_names: Tuple[str, ...] + native_skill_keys: Tuple[str, ...] + host_tool_names: Tuple[str, ...] + host_skill_keys: Tuple[str, ...] + created_at: str + expires_at: str + revoked: bool = False + + def cache_key(self) -> Tuple[str, str, str, str]: + return (self.snapshot_id, *self.caller.as_tuple()) + + def assert_usable(self, now: Optional[datetime] = None) -> None: + if self.revoked: + raise HarnessContractError("snapshot is revoked") + current = now or datetime.now(timezone.utc) + expiry = datetime.fromisoformat(self.expires_at) + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=timezone.utc) + if current >= expiry: + raise HarnessContractError("snapshot is expired") + + +@dataclass(frozen=True) +class InvocationRecord: + invocation_id: str + snapshot_id: str + tool_name: str + input_digest: str + idempotency_class: Optional[IdempotencyClass] = None + attempt: int = 1 + outcome: Optional[str] = None + + +@dataclass(frozen=True) +class EventEnvelope: + session_id: str + sequence: int + cursor: str + message_id: str + correlation_id: str + snapshot_id: str + kind: str + # The original transport frame. Metadata alone cannot reconstruct an + # assistant reply after the process-local ResponseBus has disappeared. + payload: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.sequence < 1: + raise HarnessContractError("event sequence must be >= 1") + + +@dataclass(frozen=True) +class ToolResult: + invocation_id: str + ok: bool + payload: Mapping[str, Any] + + +@dataclass(frozen=True) +class SkillMaterialization: + skill_key: str + digest: str + spec: str + body: str + + +@dataclass(frozen=True) +class SnapshotSelector: + snapshot_id: Optional[str] = None + caller: Optional[NativeCaller] = None + + +class HostCapabilityProvider(Protocol): + """Host-neutral capability surface. Implementations live outside orchestrator.""" + + async def resolve_snapshot(self, caller: NativeCaller) -> ToolSurfaceSnapshot: ... + + async def invoke( + self, + snapshot_id: str, + invocation_id: str, + tool_name: str, + payload: Mapping[str, Any], + ) -> ToolResult: ... + + async def load_skill( + self, snapshot_id: str, skill_key: str + ) -> SkillMaterialization: ... + + async def invalidate(self, selector: SnapshotSelector) -> None: ... + + +__all__ = [ + "CONTRACT_VERSION", + "FORBIDDEN_AUTHORITY_KEYS", + "FORBIDDEN_HOST_DOMAIN_KEYS", + "EventEnvelope", + "HarnessContractError", + "HostCapabilityProvider", + "IdempotencyClass", + "InvocationRecord", + "LEGAL_TURN_RUN_TRANSITIONS", + "NativeCaller", + "SkillMaterialization", + "SnapshotSelector", + "TURN_RUN_TERMINAL", + "ToolResult", + "ToolSurfaceSnapshot", + "TurnRunState", + "assert_turn_run_transition", + "native_caller_from_mapping", + "reject_host_domain_fields", + "reject_model_authority_fields", +] diff --git a/jvagent/harness/isolation.py b/jvagent/harness/isolation.py new file mode 100644 index 00000000..5402f9c2 --- /dev/null +++ b/jvagent/harness/isolation.py @@ -0,0 +1,98 @@ +"""Approved skill isolation backends (HP-09). + +Subprocess is development containment, not a sandbox. Untrusted script skills +require one of ``gvisor`` / ``firecracker`` / ``nsjail`` with the matching +binary on PATH. This module wraps a command for that binary; it does not +implement a kernel jail itself. +""" + +from __future__ import annotations + +import shlex +import shutil +from dataclasses import replace +from typing import Mapping + +from jvagent.action.code_execution.executor import ExecRequest, ExecResult, Executor + +BACKEND_BINS: Mapping[str, str] = { + "gvisor": "runsc", + "firecracker": "firecracker", + "nsjail": "nsjail", +} +APPROVED = frozenset(BACKEND_BINS) + + +def isolation_binary(backend: str) -> str: + return BACKEND_BINS.get(backend, backend) + + +def isolation_available(backend: str) -> bool: + if backend not in APPROVED: + return False + return shutil.which(isolation_binary(backend)) is not None + + +def wrap_isolated_command(backend: str, req: ExecRequest) -> ExecRequest: + """Prefix ``req.command`` with the approved backend binary. + + The wrapper is a launch prefix only. Network/fs isolation is whatever that + binary enforces when present; absence is a refuse, not a subprocess fallback. + """ + from jvagent.harness.runtime import SkillIsolationRefused + + if backend not in APPROVED: + raise SkillIsolationRefused( + f"unapproved isolation backend {backend!r}; subprocess is not a sandbox" + ) + if not isolation_available(backend): + raise SkillIsolationRefused( + f"{backend} binary {isolation_binary(backend)!r} not on PATH" + ) + bin_name = isolation_binary(backend) + cwd = shlex.quote(req.cwd) + inner = shlex.quote(req.command) + if backend == "nsjail": + cmd = f"{bin_name} -Mo --cwd {cwd} -- /bin/sh -c {inner}" + elif backend == "gvisor": + cmd = f"{bin_name} exec --cwd {cwd} -- /bin/sh -c {inner}" + else: + cmd = f"{bin_name} -- {inner}" + return replace(req, command=cmd) + + +class IsolatedExecutor: + """Executor that refuses unless an approved isolation binary is present.""" + + def __init__(self, backend: str, inner: Executor) -> None: + from jvagent.harness.runtime import SkillIsolationRefused + + if backend not in APPROVED: + raise SkillIsolationRefused( + f"unapproved isolation backend {backend!r}; subprocess is not a sandbox" + ) + if not isolation_available(backend): + raise SkillIsolationRefused( + f"{backend} binary {isolation_binary(backend)!r} not on PATH" + ) + self.backend = backend + self.inner = inner + + async def run(self, req: ExecRequest) -> ExecResult: + return await self.inner.run(wrap_isolated_command(self.backend, req)) + + +def executor_for_backend(backend: str, inner: Executor) -> Executor: + if not backend: + return inner + return IsolatedExecutor(backend, inner) + + +__all__ = [ + "BACKEND_BINS", + "IsolatedExecutor", + "executor_for_backend", + "isolation_available", + "isolation_binary", + "wrap_isolated_command", +] diff --git a/jvagent/harness/leases.py b/jvagent/harness/leases.py new file mode 100644 index 00000000..4ca07aeb --- /dev/null +++ b/jvagent/harness/leases.py @@ -0,0 +1,340 @@ +"""Session lease backends (HP-07). + +In-process dict is the default. File-backed leases let two processes contend +without Redis. Optional Redis/Dynamo adapters use SET NX / PutItem when those +clients are installed; missing clients raise, they do not silently fall back. +""" + +from __future__ import annotations + +import json +import os +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Optional, Protocol + +DEFAULT_LEASE_TTL_S = 30.0 + + +class LeaseBackend(Protocol): + def acquire( + self, session_id: str, worker_id: str, *, ttl_s: float = DEFAULT_LEASE_TTL_S + ) -> Dict[str, Any]: ... + + def bind(self, session_id: str, worker_id: str, correlation_id: str) -> None: ... + + def renew( + self, session_id: str, worker_id: str, *, ttl_s: float = DEFAULT_LEASE_TTL_S + ) -> None: ... + + def release(self, session_id: str, worker_id: str) -> None: ... + + def get(self, session_id: str) -> Optional[Dict[str, Any]]: ... + + +@dataclass +class InProcessLeaseBackend: + """Wraps ``HarnessStore.leases``.""" + + leases: Dict[str, Dict[str, Any]] + + def acquire( + self, session_id: str, worker_id: str, *, ttl_s: float = DEFAULT_LEASE_TTL_S + ) -> Dict[str, Any]: + now = time.monotonic() + held = self.leases.get(session_id) + if held and held["worker_id"] != worker_id and held["expires_at"] > now: + from jvagent.harness.runtime import SessionBusy + + raise SessionBusy(f"session {session_id} leased by {held['worker_id']}") + rec = { + "worker_id": worker_id, + "expires_at": now + ttl_s, + "correlation_id": (held or {}).get("correlation_id", ""), + } + if held and held["expires_at"] <= now: + rec["expired_correlation_id"] = held.get("correlation_id") or "" + self.leases[session_id] = rec + return rec + + def bind(self, session_id: str, worker_id: str, correlation_id: str) -> None: + held = self.leases.get(session_id) + if held and held["worker_id"] == worker_id: + held["correlation_id"] = correlation_id + + def renew( + self, session_id: str, worker_id: str, *, ttl_s: float = DEFAULT_LEASE_TTL_S + ) -> None: + held = self.leases.get(session_id) + if not held or held["worker_id"] != worker_id: + from jvagent.harness.runtime import SessionBusy + + raise SessionBusy(f"session {session_id} not held by {worker_id}") + held["expires_at"] = time.monotonic() + ttl_s + + def release(self, session_id: str, worker_id: str) -> None: + held = self.leases.get(session_id) + if held and held["worker_id"] == worker_id: + self.leases.pop(session_id, None) + + def get(self, session_id: str) -> Optional[Dict[str, Any]]: + return self.leases.get(session_id) + + +class FileLeaseBackend: + """JSON file + exclusive create. Two OS processes can contend.""" + + def __init__(self, path: Path) -> None: + self.path = path + self.path.parent.mkdir(parents=True, exist_ok=True) + if not self.path.exists(): + self.path.write_text("{}", encoding="utf-8") + + def _load(self) -> Dict[str, Any]: + try: + return json.loads(self.path.read_text(encoding="utf-8") or "{}") + except json.JSONDecodeError: + return {} + + def _save(self, data: Dict[str, Any]) -> None: + tmp = self.path.with_suffix(self.path.suffix + ".tmp") + tmp.write_text(json.dumps(data), encoding="utf-8") + os.replace(tmp, self.path) + + def acquire( + self, session_id: str, worker_id: str, *, ttl_s: float = DEFAULT_LEASE_TTL_S + ) -> Dict[str, Any]: + now = time.time() + data = self._load() + held = data.get(session_id) + if held and held["worker_id"] != worker_id and held["expires_at"] > now: + from jvagent.harness.runtime import SessionBusy + + raise SessionBusy(f"session {session_id} leased by {held['worker_id']}") + rec = { + "worker_id": worker_id, + "expires_at": now + ttl_s, + "correlation_id": (held or {}).get("correlation_id", ""), + } + if held and held["expires_at"] <= now: + rec["expired_correlation_id"] = held.get("correlation_id") or "" + data[session_id] = rec + self._save(data) + return rec + + def bind(self, session_id: str, worker_id: str, correlation_id: str) -> None: + data = self._load() + held = data.get(session_id) + if held and held["worker_id"] == worker_id: + held["correlation_id"] = correlation_id + self._save(data) + + def renew( + self, session_id: str, worker_id: str, *, ttl_s: float = DEFAULT_LEASE_TTL_S + ) -> None: + data = self._load() + held = data.get(session_id) + if not held or held["worker_id"] != worker_id: + from jvagent.harness.runtime import SessionBusy + + raise SessionBusy(f"session {session_id} not held by {worker_id}") + held["expires_at"] = time.time() + ttl_s + self._save(data) + + def release(self, session_id: str, worker_id: str) -> None: + data = self._load() + held = data.get(session_id) + if held and held["worker_id"] == worker_id: + data.pop(session_id, None) + self._save(data) + + def get(self, session_id: str) -> Optional[Dict[str, Any]]: + return self._load().get(session_id) + + +class RedisLeaseBackend: + """Optional. Requires ``redis`` package. SET key NX EX.""" + + def __init__(self, client: Any, *, prefix: str = "jvagent:lease:") -> None: + self.client = client + self.prefix = prefix + + def _key(self, session_id: str) -> str: + return f"{self.prefix}{session_id}" + + def acquire( + self, session_id: str, worker_id: str, *, ttl_s: float = DEFAULT_LEASE_TTL_S + ) -> Dict[str, Any]: + key = self._key(session_id) + ok = self.client.set(key, worker_id, nx=True, ex=int(max(ttl_s, 1))) + if not ok: + holder = self.client.get(key) + holder_s = holder.decode() if isinstance(holder, bytes) else holder + if holder_s != worker_id: + from jvagent.harness.runtime import SessionBusy + + raise SessionBusy(f"session {session_id} leased by {holder_s}") + return { + "worker_id": worker_id, + "expires_at": time.time() + ttl_s, + "correlation_id": "", + } + + def bind(self, session_id: str, worker_id: str, correlation_id: str) -> None: + self.client.set(self._key(session_id) + ":corr", correlation_id, xx=True) + + def renew( + self, session_id: str, worker_id: str, *, ttl_s: float = DEFAULT_LEASE_TTL_S + ) -> None: + key = self._key(session_id) + holder = self.client.get(key) + holder_s = holder.decode() if isinstance(holder, bytes) else holder + if holder_s != worker_id: + from jvagent.harness.runtime import SessionBusy + + raise SessionBusy(f"session {session_id} not held by {worker_id}") + self.client.expire(key, int(max(ttl_s, 1))) + + def release(self, session_id: str, worker_id: str) -> None: + key = self._key(session_id) + holder = self.client.get(key) + holder_s = holder.decode() if isinstance(holder, bytes) else holder + if holder_s == worker_id: + self.client.delete(key) + + def get(self, session_id: str) -> Optional[Dict[str, Any]]: + holder = self.client.get(self._key(session_id)) + if not holder: + return None + holder_s = holder.decode() if isinstance(holder, bytes) else holder + return {"worker_id": holder_s, "correlation_id": "", "expires_at": 0} + + +class DynamoLeaseBackend: + """Optional. Requires a DynamoDB-like client with put_item/get_item/delete_item. + + Missing client is a raise, not a silent in-process fallback. + """ + + def __init__(self, client: Any, *, table: str = "jvagent-leases") -> None: + self.client = client + self.table = table + + def acquire( + self, session_id: str, worker_id: str, *, ttl_s: float = DEFAULT_LEASE_TTL_S + ) -> Dict[str, Any]: + expires = int(time.time() + ttl_s) + existing = self.client.get_item( + TableName=self.table, Key={"session_id": {"S": session_id}} + ) + item = (existing or {}).get("Item") or {} + holder = ((item.get("worker_id") or {}).get("S")) or "" + exp = int(((item.get("expires_at") or {}).get("N")) or 0) + now = int(time.time()) + if holder and holder != worker_id and exp > now: + from jvagent.harness.runtime import SessionBusy + + raise SessionBusy(f"session {session_id} leased by {holder}") + rec = { + "worker_id": worker_id, + "expires_at": float(expires), + "correlation_id": ((item.get("correlation_id") or {}).get("S")) or "", + } + if holder and exp <= now: + rec["expired_correlation_id"] = rec["correlation_id"] + self.client.put_item( + TableName=self.table, + Item={ + "session_id": {"S": session_id}, + "worker_id": {"S": worker_id}, + "expires_at": {"N": str(expires)}, + "correlation_id": {"S": rec["correlation_id"]}, + }, + ) + return rec + + def bind(self, session_id: str, worker_id: str, correlation_id: str) -> None: + held = self.get(session_id) + if held and held["worker_id"] == worker_id: + held["correlation_id"] = correlation_id + self.client.put_item( + TableName=self.table, + Item={ + "session_id": {"S": session_id}, + "worker_id": {"S": worker_id}, + "expires_at": {"N": str(int(held.get("expires_at") or 0))}, + "correlation_id": {"S": correlation_id}, + }, + ) + + def renew( + self, session_id: str, worker_id: str, *, ttl_s: float = DEFAULT_LEASE_TTL_S + ) -> None: + held = self.get(session_id) + if not held or held["worker_id"] != worker_id: + from jvagent.harness.runtime import SessionBusy + + raise SessionBusy(f"session {session_id} not held by {worker_id}") + held["expires_at"] = time.time() + ttl_s + self.bind(session_id, worker_id, held.get("correlation_id") or "") + + def release(self, session_id: str, worker_id: str) -> None: + held = self.get(session_id) + if held and held["worker_id"] == worker_id: + self.client.delete_item( + TableName=self.table, Key={"session_id": {"S": session_id}} + ) + + def get(self, session_id: str) -> Optional[Dict[str, Any]]: + existing = self.client.get_item( + TableName=self.table, Key={"session_id": {"S": session_id}} + ) + item = (existing or {}).get("Item") or {} + if not item: + return None + return { + "worker_id": ((item.get("worker_id") or {}).get("S")) or "", + "correlation_id": ((item.get("correlation_id") or {}).get("S")) or "", + "expires_at": float(((item.get("expires_at") or {}).get("N")) or 0), + } + + +def lease_backend_for( + kind: str, + *, + leases: Optional[Dict[str, Dict[str, Any]]] = None, + path: Optional[Path] = None, + redis_client: Any = None, + dynamo_client: Any = None, +) -> LeaseBackend: + if kind in ("", "memory", "inprocess"): + return InProcessLeaseBackend(leases if leases is not None else {}) + if kind == "file": + if path is None: + raise ValueError("file lease backend requires path") + return FileLeaseBackend(path) + if kind == "redis": + if redis_client is None: + raise ValueError( + "redis lease backend requires a client; no silent fallback" + ) + return RedisLeaseBackend(redis_client) + if kind in ("dynamo", "dynamodb"): + if dynamo_client is None: + raise ValueError( + "dynamo lease backend requires a client; no silent fallback" + ) + return DynamoLeaseBackend(dynamo_client) + raise ValueError(f"unknown lease backend {kind!r}") + + +__all__ = [ + "DEFAULT_LEASE_TTL_S", + "DynamoLeaseBackend", + "FileLeaseBackend", + "InProcessLeaseBackend", + "LeaseBackend", + "RedisLeaseBackend", + "lease_backend_for", +] diff --git a/jvagent/harness/persist.py b/jvagent/harness/persist.py new file mode 100644 index 00000000..2a3fa0fd --- /dev/null +++ b/jvagent/harness/persist.py @@ -0,0 +1,249 @@ +"""Durable dump/load of a HarnessStore (HP-06 transport, HP-11 retention). + +JSON file on disk. Two workers share a path. Not Redis. Not a claim of +exactly-once channel send. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Tuple + +from jvagent.harness.contracts import ( + EventEnvelope, + IdempotencyClass, + InvocationRecord, + SkillMaterialization, + ToolSurfaceSnapshot, + TurnRunState, + native_caller_from_mapping, +) +from jvagent.harness.runtime import ( + HarnessStore, + SkillManifest, + StageRecord, + TurnRunJournal, +) + +STORE_FORMAT = 1 + + +def _tuple_key(parts: Tuple[str, ...]) -> str: + return "\x1f".join(parts) + + +def _split_pair(raw: str) -> Tuple[str, str]: + a, b = raw.split("\x1f", 1) + return a, b + + +def _split_triple(raw: str) -> Tuple[str, str, str]: + a, rest = raw.split("\x1f", 1) + b, c = rest.split("\x1f", 1) + return a, b, c + + +def dump_store(store: HarnessStore, path: Path) -> None: + """Write identities, snapshots, journals, ledger, outbox, traces, skills.""" + with store.lock: + snapshots = {} + for sid, snap in store.snapshots.items(): + blob = asdict(snap) + blob["caller"] = snap.caller.to_mapping() + snapshots[sid] = blob + current = {_tuple_key(k): v for k, v in store.current_snapshot.items()} + generation = {_tuple_key(k): v for k, v in store.generation.items()} + identities = {_tuple_key(k): v for k, v in store.identities.items()} + conversations = {_tuple_key(k): v for k, v in store.conversations.items()} + runs: Dict[str, Dict[str, Any]] = {} + for corr, journal in store.runs.items(): + runs[corr] = { + "correlation_id": journal.correlation_id, + "caller": journal.caller.to_mapping(), + "state": journal.state.value, + "snapshot_id": journal.snapshot_id, + "interaction_id": journal.interaction_id, + "seq": journal.seq, + "worker_id": journal.worker_id, + "entries": list(journal.entries), + "completed_invocation_ids": list(journal.completed_invocation_ids), + "observation_refs": list(journal.observation_refs), + "plan_phase": journal.plan_phase, + "reason": journal.reason, + } + invocations: Dict[str, Dict[str, Any]] = {} + for key, rec in store.invocations.items(): + rec_map = asdict(rec) + klass = rec.idempotency_class + rec_map["idempotency_class"] = klass.value if klass else None + invocations[key] = rec_map + outbox = { + sid: [asdict(e) for e in events] for sid, events in store.outbox.items() + } + manifests = {digest: asdict(m) for digest, m in store.skill_manifests.items()} + stages = {} + for p, rec in store.stages.items(): + stages[p] = { + "caller": rec.caller.to_mapping(), + "snapshot_id": rec.snapshot_id, + "digest": rec.digest, + "path": rec.path, + "active": rec.active, + } + payload = { + "format": STORE_FORMAT, + "dumped_at": datetime.now(timezone.utc).isoformat(), + "identities": identities, + "conversations": conversations, + "snapshots": snapshots, + "current_snapshot": current, + "generation": generation, + "runs": runs, + "runs_by_interaction": dict(store.runs_by_interaction), + "invocations": invocations, + "invocation_results": dict(store.invocation_results), + "outbox": outbox, + "traces": {k: list(v) for k, v in store.traces.items()}, + "skill_manifests": manifests, + "stages": stages, + "host_tools": {k: list(v) for k, v in store.host_tools.items()}, + "host_skills": {k: list(v) for k, v in store.host_skills.items()}, + "host_skill_materializations": { + _tuple_key(key): asdict(value) + for key, value in store.host_skill_materializations.items() + }, + "revoked_host_tools": { + k: sorted(v) for k, v in store.revoked_host_tools.items() + }, + "revoked_manifests": sorted(getattr(store, "revoked_manifests", set())), + "leases": dict(store.leases), + "draining": store.draining, + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, default=str), encoding="utf-8") + + +def load_store(path: Path, store: Optional[HarnessStore] = None) -> HarnessStore: + """Hydrate a store from :func:`dump_store` JSON. Callables are not restored.""" + target = store or HarnessStore() + raw: Mapping[str, Any] = json.loads(path.read_text(encoding="utf-8")) + with target.lock: + target.identities = { + _split_pair(k): v for k, v in (raw.get("identities") or {}).items() + } + target.conversations = { + _split_pair(k): v for k, v in (raw.get("conversations") or {}).items() + } + snaps: Dict[str, ToolSurfaceSnapshot] = {} + for sid, blob in (raw.get("snapshots") or {}).items(): + snaps[sid] = ToolSurfaceSnapshot( + snapshot_id=str(blob["snapshot_id"]), + caller=native_caller_from_mapping(blob["caller"]), + native_tool_names=tuple(blob.get("native_tool_names") or ()), + native_skill_keys=tuple(blob.get("native_skill_keys") or ()), + host_tool_names=tuple(blob.get("host_tool_names") or ()), + host_skill_keys=tuple(blob.get("host_skill_keys") or ()), + created_at=str(blob.get("created_at") or ""), + expires_at=str(blob.get("expires_at") or ""), + revoked=bool(blob.get("revoked")), + ) + target.snapshots = snaps + target.current_snapshot = { + _split_triple(k): v for k, v in (raw.get("current_snapshot") or {}).items() + } + target.generation = { + _split_triple(k): int(v) for k, v in (raw.get("generation") or {}).items() + } + runs: Dict[str, TurnRunJournal] = {} + for corr, blob in (raw.get("runs") or {}).items(): + runs[corr] = TurnRunJournal( + correlation_id=str(blob["correlation_id"]), + caller=native_caller_from_mapping(blob["caller"]), + state=TurnRunState(str(blob["state"])), + snapshot_id=str(blob.get("snapshot_id") or ""), + interaction_id=str(blob.get("interaction_id") or ""), + seq=int(blob.get("seq") or 0), + worker_id=str(blob.get("worker_id") or ""), + entries=list(blob.get("entries") or []), + completed_invocation_ids=list( + blob.get("completed_invocation_ids") or [] + ), + observation_refs=list(blob.get("observation_refs") or []), + plan_phase=str(blob.get("plan_phase") or ""), + reason=str(blob.get("reason") or ""), + ) + target.runs = runs + target.runs_by_interaction = dict(raw.get("runs_by_interaction") or {}) + invocations: Dict[str, InvocationRecord] = {} + for key, rec_map in (raw.get("invocations") or {}).items(): + klass_raw = rec_map.get("idempotency_class") + invocations[key] = InvocationRecord( + invocation_id=str(rec_map["invocation_id"]), + snapshot_id=str(rec_map.get("snapshot_id") or ""), + tool_name=str(rec_map.get("tool_name") or ""), + input_digest=str(rec_map.get("input_digest") or ""), + idempotency_class=(IdempotencyClass(klass_raw) if klass_raw else None), + attempt=int(rec_map.get("attempt") or 1), + outcome=rec_map.get("outcome"), + ) + target.invocations = invocations + target.invocation_results = { + k: str(v) for k, v in (raw.get("invocation_results") or {}).items() + } + outbox: Dict[str, List[EventEnvelope]] = {} + for sid, events in (raw.get("outbox") or {}).items(): + outbox[sid] = [EventEnvelope(**e) for e in events] + target.outbox = outbox + target.traces = {k: list(v) for k, v in (raw.get("traces") or {}).items()} + manifests: Dict[str, SkillManifest] = {} + for digest, blob in (raw.get("skill_manifests") or {}).items(): + manifests[digest] = SkillManifest( + skill_key=str(blob["skill_key"]), + source=str(blob.get("source") or ""), + digest=str(blob["digest"]), + declared_tools=tuple(blob.get("declared_tools") or ()), + capabilities=tuple(blob.get("capabilities") or ()), + trust_tier=str(blob.get("trust_tier") or "trusted"), + spec=str(blob.get("spec") or "jv"), + signature=str(blob.get("signature") or ""), + body=str(blob.get("body") or ""), + ) + target.skill_manifests = manifests + stages: Dict[str, StageRecord] = {} + for p, blob in (raw.get("stages") or {}).items(): + stages[p] = StageRecord( + caller=native_caller_from_mapping(blob["caller"]), + snapshot_id=str(blob["snapshot_id"]), + digest=str(blob["digest"]), + path=str(blob["path"]), + active=bool(blob.get("active", True)), + ) + target.stages = stages + target.host_tools = { + k: list(v) for k, v in (raw.get("host_tools") or {}).items() + } + target.host_skills = { + k: list(v) for k, v in (raw.get("host_skills") or {}).items() + } + target.host_skill_materializations = { + _split_pair(key): SkillMaterialization( + skill_key=str(value["skill_key"]), + digest=str(value["digest"]), + spec=str(value["spec"]), + body=str(value["body"]), + ) + for key, value in (raw.get("host_skill_materializations") or {}).items() + } + target.revoked_host_tools = { + k: set(v) for k, v in (raw.get("revoked_host_tools") or {}).items() + } + target.revoked_manifests = set(raw.get("revoked_manifests") or []) + target.leases = dict(raw.get("leases") or {}) + target.draining = bool(raw.get("draining")) + return target + + +__all__ = ["STORE_FORMAT", "dump_store", "load_store"] diff --git a/jvagent/harness/provider.py b/jvagent/harness/provider.py new file mode 100644 index 00000000..d993f4b4 --- /dev/null +++ b/jvagent/harness/provider.py @@ -0,0 +1,160 @@ +"""HostCapabilityProvider adapters (HP-08). Native / embedded / remote share types.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Mapping, Optional + +from jvagent.harness.contracts import ( + HarnessContractError, + NativeCaller, + SkillMaterialization, + SnapshotSelector, + ToolResult, + ToolSurfaceSnapshot, + native_caller_from_mapping, + reject_model_authority_fields, +) +from jvagent.harness.runtime import HarnessRuntime, get_runtime + + +class LocalHostProvider: + """In-process reference provider. Host tools/skills are per session_id.""" + + def __init__(self, runtime: Optional[HarnessRuntime] = None) -> None: + self.runtime = runtime or get_runtime() + + async def resolve_snapshot(self, caller: NativeCaller) -> ToolSurfaceSnapshot: + return self.runtime.admit_snapshot(caller, force_new=True) + + async def invoke( + self, + snapshot_id: str, + invocation_id: str, + tool_name: str, + payload: Mapping[str, Any], + ) -> ToolResult: + reject_model_authority_fields(payload) + snap = self.runtime.require_usable(snapshot_id) + if tool_name in snap.host_tool_names: + runner = self.runtime.host_runner(snap.caller.session_id, tool_name) + if runner is None: + raise HarnessContractError( + f"no runner registered for host tool {tool_name!r}" + ) + result = runner(dict(payload)) + if hasattr(result, "__await__"): + result = await result # type: ignore[misc] + return ToolResult( + invocation_id=invocation_id, + ok=True, + payload=( + dict(result) if isinstance(result, Mapping) else {"result": result} + ), + ) + if ( + tool_name not in snap.native_tool_names + and tool_name not in snap.host_tool_names + ): + raise HarnessContractError( + f"tool {tool_name!r} not on snapshot {snapshot_id}" + ) + raise HarnessContractError( + f"native tool {tool_name!r} is dispatched by wrap_action_tool, not HostCapabilityProvider" + ) + + async def load_skill( + self, snapshot_id: str, skill_key: str + ) -> SkillMaterialization: + snap = self.runtime.require_usable(snapshot_id) + if ( + skill_key not in snap.host_skill_keys + and skill_key not in snap.native_skill_keys + ): + raise HarnessContractError( + f"skill {skill_key!r} not on snapshot {snapshot_id}" + ) + if skill_key in snap.host_skill_keys: + materialization = self.runtime.host_skill_materialization( + snap.caller.session_id, skill_key + ) + if materialization is None: + raise HarnessContractError( + f"host skill {skill_key!r} has no registered materialization" + ) + return materialization + digest = f"digest-{skill_key}" + return SkillMaterialization( + skill_key=skill_key, + digest=digest, + spec="jv", + body=f"# {skill_key}\n", + ) + + async def invalidate(self, selector: SnapshotSelector) -> None: + self.runtime.invalidate(selector) + + +class EmbeddedHostAdapter(LocalHostProvider): + """Embedded transport: identical types, in-process.""" + + +class RemoteHostAdapter: + """Remote transport: JSON wire of the same types. No host-domain fields.""" + + def __init__(self, inner: Optional[LocalHostProvider] = None) -> None: + self.inner = inner or LocalHostProvider() + + @staticmethod + def encode_caller(caller: NativeCaller) -> str: + blob = json.dumps(caller.to_mapping(), sort_keys=True) + parsed = json.loads(blob) + native_caller_from_mapping(parsed) + return blob + + @staticmethod + def decode_caller(blob: str) -> NativeCaller: + return native_caller_from_mapping(json.loads(blob)) + + async def resolve_snapshot(self, caller: NativeCaller) -> ToolSurfaceSnapshot: + wire = self.encode_caller(caller) + return await self.inner.resolve_snapshot(self.decode_caller(wire)) + + async def invoke( + self, + snapshot_id: str, + invocation_id: str, + tool_name: str, + payload: Mapping[str, Any], + ) -> ToolResult: + encoded = json.dumps(dict(payload), sort_keys=True) + decoded: Dict[str, Any] = json.loads(encoded) + return await self.inner.invoke(snapshot_id, invocation_id, tool_name, decoded) + + async def load_skill( + self, snapshot_id: str, skill_key: str + ) -> SkillMaterialization: + return await self.inner.load_skill(snapshot_id, skill_key) + + async def invalidate(self, selector: SnapshotSelector) -> None: + await self.inner.invalidate(selector) + + +def provider_for(transport: str, runtime: Optional[HarnessRuntime] = None) -> Any: + rt = runtime or get_runtime() + local = LocalHostProvider(rt) + if transport == "native": + return local + if transport == "embedded": + return EmbeddedHostAdapter(rt) + if transport == "remote": + return RemoteHostAdapter(local) + raise HarnessContractError(f"unknown provider transport {transport!r}") + + +__all__ = [ + "EmbeddedHostAdapter", + "LocalHostProvider", + "RemoteHostAdapter", + "provider_for", +] diff --git a/jvagent/harness/release.py b/jvagent/harness/release.py new file mode 100644 index 00000000..22f1eeb5 --- /dev/null +++ b/jvagent/harness/release.py @@ -0,0 +1,85 @@ +"""Contract versions and deployment matrix (HP-12).""" + +from __future__ import annotations + +from typing import Dict + +from jvagent.harness.contracts import CONTRACT_VERSION + +NATIVE_CALLER_VERSION = CONTRACT_VERSION +SNAPSHOT_VERSION = CONTRACT_VERSION +PROVIDER_VERSION = CONTRACT_VERSION +EVENT_ENVELOPE_VERSION = CONTRACT_VERSION + +# guaranteed | degraded | unsupported +# JSON/SQLite are single-writer. Active-active needs a store that shares the +# HarnessStore (or Redis/Dynamo leases). Never treat process-local as distributed. +DEPLOYMENT_MATRIX: Dict[str, Dict[str, str]] = { + "json": { + "local": "guaranteed", + "single-worker": "guaranteed", + "active-active": "unsupported", + }, + "sqlite": { + "local": "guaranteed", + "single-worker": "guaranteed", + "active-active": "unsupported", + }, + "mongodb": { + "local": "guaranteed", + "single-worker": "guaranteed", + "active-active": "degraded", + }, + "dynamodb": { + "local": "guaranteed", + "single-worker": "guaranteed", + "active-active": "guaranteed", + }, + "postgres": { + "local": "guaranteed", + "single-worker": "guaranteed", + "active-active": "degraded", + }, +} + + +def cell(backend: str, mode: str) -> str: + row = DEPLOYMENT_MATRIX.get(backend) + if row is None: + return "unsupported" + return row.get(mode, "unsupported") + + +def release_record(*, digest: str, topology: str) -> dict: + return { + "artifact_digest": digest, + "contract_versions": { + "native_caller": NATIVE_CALLER_VERSION, + "snapshot": SNAPSHOT_VERSION, + "provider": PROVIDER_VERSION, + "event_envelope": EVENT_ENVELOPE_VERSION, + }, + "topology": topology, + "matrix": DEPLOYMENT_MATRIX, + "limitations": [ + "JSON and SQLite are single-writer; do not claim active-active.", + "Outbox dump/load is JSON on disk via dump_store; Redis/Dynamo streams are not implied.", + "Session leases: in-process default; file/redis/dynamo adapters require an explicit client or path (no silent fallback).", + "Subprocess skill execution is development containment, not a sandbox.", + "Untrusted skills refuse unless gvisor/firecracker/nsjail is on PATH.", + "Exactly-once third-party effects require an idempotency mechanism.", + "HostCapabilityProvider.invoke runs a registered host runner; native tools stay on wrap_action_tool.", + ], + "rollback": "revert to process-local caches/bus; disable drain and shared store.", + } + + +__all__ = [ + "DEPLOYMENT_MATRIX", + "EVENT_ENVELOPE_VERSION", + "NATIVE_CALLER_VERSION", + "PROVIDER_VERSION", + "SNAPSHOT_VERSION", + "cell", + "release_record", +] diff --git a/jvagent/harness/runtime.py b/jvagent/harness/runtime.py new file mode 100644 index 00000000..ffed006c --- /dev/null +++ b/jvagent/harness/runtime.py @@ -0,0 +1,1079 @@ +"""Store-backed harness runtime (HP-02 … HP-12). + +Process-local default. Inject a shared :class:`HarnessStore` for two-worker +tests. TurnRun is a journal Object (I-GRAPH-02), not a conversation Node. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import threading +import time +import uuid +from dataclasses import asdict, dataclass, field, replace +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Mapping, Optional, Tuple + +from jvagent.harness.contracts import ( + CONTRACT_VERSION, + TURN_RUN_TERMINAL, + EventEnvelope, + HarnessContractError, + IdempotencyClass, + InvocationRecord, + NativeCaller, + SkillMaterialization, + SnapshotSelector, + ToolSurfaceSnapshot, + TurnRunState, + assert_turn_run_transition, + native_caller_from_mapping, + reject_host_domain_fields, + reject_model_authority_fields, +) + +SAME_SESSION_POLICY = "lease" +SNAPSHOT_TTL = timedelta(hours=1) +DEFAULT_LEASE_TTL_S = 30.0 +MAX_EVENTS_PER_SESSION = 10_000 +MAX_OBSERVATION_CHARS = 8_000 +MAX_TRACE_SPANS = 10_000 +APPROVED_ISOLATION_BACKENDS = frozenset({"gvisor", "firecracker", "nsjail"}) +CHECKPOINT_KIND = "harness.turn_run" +TRACE_KIND = "harness.trace" + +_log = logging.getLogger("jvagent.harness") + +_runtime_guard = threading.Lock() +_runtime: Optional["HarnessRuntime"] = None + + +class AdmissionRefused(HarnessContractError): + """Turn or snapshot admission rejected (drain, lease, identity).""" + + +class SessionBusy(AdmissionRefused): + """Same-session policy is lease; another worker holds the session.""" + + +class SkillIsolationRefused(HarnessContractError): + """Untrusted skill has no approved isolation backend.""" + + +@dataclass +class TurnRunJournal: + """Log-shaped TurnRun. Not a graph Node.""" + + correlation_id: str + caller: NativeCaller + state: TurnRunState + snapshot_id: str + interaction_id: str = "" + seq: int = 0 + worker_id: str = "" + entries: List[Dict[str, Any]] = field(default_factory=list) + completed_invocation_ids: List[str] = field(default_factory=list) + observation_refs: List[str] = field(default_factory=list) + plan_phase: str = "" + reason: str = "" + + +@dataclass +class SkillManifest: + skill_key: str + source: str + digest: str + declared_tools: Tuple[str, ...] + capabilities: Tuple[str, ...] + trust_tier: str + spec: str = "jv" + signature: str = "" + body: str = "" + + +@dataclass +class StageRecord: + caller: NativeCaller + snapshot_id: str + digest: str + path: str + active: bool = True + + +@dataclass +class HarnessStore: + """Shared backend. One store per process by default; share for multi-worker tests.""" + + identities: Dict[Tuple[str, str], str] = field(default_factory=dict) + conversations: Dict[Tuple[str, str], str] = field(default_factory=dict) + snapshots: Dict[str, ToolSurfaceSnapshot] = field(default_factory=dict) + current_snapshot: Dict[Tuple[str, str, str], str] = field(default_factory=dict) + generation: Dict[Tuple[str, str, str], int] = field(default_factory=dict) + runs: Dict[str, TurnRunJournal] = field(default_factory=dict) + runs_by_interaction: Dict[str, str] = field(default_factory=dict) + invocations: Dict[str, InvocationRecord] = field(default_factory=dict) + invocation_results: Dict[str, str] = field(default_factory=dict) + outbox: Dict[str, List[EventEnvelope]] = field(default_factory=dict) + leases: Dict[str, Dict[str, Any]] = field(default_factory=dict) + traces: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict) + skill_manifests: Dict[str, SkillManifest] = field(default_factory=dict) + stages: Dict[str, StageRecord] = field(default_factory=dict) + host_tools: Dict[str, List[str]] = field(default_factory=dict) + host_skills: Dict[str, List[str]] = field(default_factory=dict) + host_skill_materializations: Dict[Tuple[str, str], SkillMaterialization] = field( + default_factory=dict + ) + revoked_host_tools: Dict[str, set] = field(default_factory=dict) + revoked_manifests: set = field(default_factory=set) + breaker_states: Dict[str, Any] = field(default_factory=dict) + draining: bool = False + lock: threading.Lock = field(default_factory=threading.Lock) + + +class HarnessRuntime: + """Admission, snapshots, journal, ledger, outbox, leases, traces, skills.""" + + def __init__( + self, + store: Optional[HarnessStore] = None, + *, + worker_id: str = "", + isolation_backend: str = "", + skill_signing_key: str = "", + lease_backend: Any = None, + max_trace_spans: int = MAX_TRACE_SPANS, + ) -> None: + self.store = store or HarnessStore() + self.worker_id = worker_id or f"worker-{uuid.uuid4().hex[:8]}" + self.isolation_backend = isolation_backend + self.skill_signing_key = skill_signing_key + self.lease_backend = lease_backend + self.max_trace_spans = max_trace_spans + self.contract_version = CONTRACT_VERSION + self._host_runners: Dict[Tuple[str, str], Any] = {} + self._compensators: Dict[str, Any] = {} + + # -- identity (HP-02) ------------------------------------------------- + + def upsert_user(self, memory_id: str, user_id: str) -> str: + key = (memory_id, user_id) + with self.store.lock: + node = self.store.identities.get(key) + if node is None: + node = f"user:{memory_id}:{user_id}" + self.store.identities[key] = node + return node + + def upsert_conversation(self, memory_id: str, session_id: str) -> str: + key = (memory_id, session_id) + with self.store.lock: + node = self.store.conversations.get(key) + if node is None: + node = f"conv:{memory_id}:{session_id}" + self.store.conversations[key] = node + return node + + def admit_payload(self, data: Optional[Mapping[str, Any]]) -> None: + if data: + reject_host_domain_fields(data) + + def new_correlation(self) -> str: + return f"corr-{uuid.uuid4().hex}" + + # -- snapshots (HP-03) ------------------------------------------------ + + def admit_snapshot( + self, + caller: NativeCaller, + *, + native_tool_names: Tuple[str, ...] = (), + native_skill_keys: Tuple[str, ...] = (), + force_new: bool = False, + ) -> ToolSurfaceSnapshot: + if self.store.draining: + raise AdmissionRefused("admissions stopped: worker draining") + key = caller.as_tuple() + now = datetime.now(timezone.utc) + with self.store.lock: + current_id = self.store.current_snapshot.get(key) + if current_id and not force_new: + snap = self.store.snapshots.get(current_id) + if snap is not None: + try: + snap.assert_usable(now) + return snap + except HarnessContractError: + pass + host_tools = tuple( + t + for t in self.store.host_tools.get(caller.session_id, []) + if t not in self.store.revoked_host_tools.get(caller.session_id, set()) + ) + host_skills = tuple(self.store.host_skills.get(caller.session_id, [])) + snap = ToolSurfaceSnapshot( + snapshot_id=f"snap-{uuid.uuid4().hex}", + caller=caller, + native_tool_names=tuple(native_tool_names), + native_skill_keys=tuple(native_skill_keys), + host_tool_names=host_tools, + host_skill_keys=host_skills, + created_at=now.isoformat(), + expires_at=(now + SNAPSHOT_TTL).isoformat(), + revoked=False, + ) + self.store.snapshots[snap.snapshot_id] = snap + self.store.current_snapshot[key] = snap.snapshot_id + self.store.generation[key] = self.store.generation.get(key, 0) + 1 + return snap + + def get_snapshot(self, snapshot_id: str) -> Optional[ToolSurfaceSnapshot]: + return self.store.snapshots.get(snapshot_id) + + def invalidate(self, selector: SnapshotSelector) -> None: + with self.store.lock: + ids: List[str] = [] + if selector.snapshot_id: + ids.append(selector.snapshot_id) + if selector.caller is not None: + current = self.store.current_snapshot.get(selector.caller.as_tuple()) + if current: + ids.append(current) + for sid in ids: + snap = self.store.snapshots.get(sid) + if snap is None: + continue + self.store.snapshots[sid] = replace(snap, revoked=True) + key = snap.caller.as_tuple() + if self.store.current_snapshot.get(key) == sid: + self.store.current_snapshot.pop(key, None) + + def update_snapshot_descriptors( + self, + snapshot_id: str, + *, + native_tool_names: Tuple[str, ...] = (), + native_skill_keys: Tuple[str, ...] = (), + ) -> ToolSurfaceSnapshot: + snap = self.store.snapshots.get(snapshot_id) + if snap is None: + raise HarnessContractError(f"unknown snapshot {snapshot_id}") + snap.assert_usable() + updated = replace( + snap, + native_tool_names=tuple(native_tool_names) or snap.native_tool_names, + native_skill_keys=tuple(native_skill_keys) or snap.native_skill_keys, + ) + with self.store.lock: + self.store.snapshots[snapshot_id] = updated + return updated + + def require_usable(self, snapshot_id: str) -> ToolSurfaceSnapshot: + snap = self.store.snapshots.get(snapshot_id) + if snap is None: + raise HarnessContractError(f"unknown snapshot {snapshot_id}") + snap.assert_usable() + return snap + + # -- TurnRun journal (HP-04) ------------------------------------------ + + def start_turn( + self, + correlation_id: str, + caller: NativeCaller, + snapshot: ToolSurfaceSnapshot, + *, + interaction_id: str = "", + plan_phase: str = "", + ) -> TurnRunJournal: + if self.store.draining: + raise AdmissionRefused("admissions stopped: worker draining") + journal = TurnRunJournal( + correlation_id=correlation_id, + caller=caller, + state=TurnRunState.ACCEPTED, + snapshot_id=snapshot.snapshot_id, + interaction_id=interaction_id, + worker_id=self.worker_id, + plan_phase=plan_phase, + ) + self._append_journal(journal, TurnRunState.RUNNING, "admitted") + with self.store.lock: + self.store.runs[correlation_id] = journal + if interaction_id: + self.store.runs_by_interaction[interaction_id] = correlation_id + self.record_span(correlation_id, "admission", caller=caller) + return journal + + def get_run(self, correlation_id: str) -> Optional[TurnRunJournal]: + return self.store.runs.get(correlation_id) + + def transition( + self, + correlation_id: str, + dst: TurnRunState, + *, + reason: str = "", + ) -> TurnRunJournal: + journal = self._require_run(correlation_id) + assert_turn_run_transition(journal.state, dst) + self._append_journal(journal, dst, reason) + return journal + + def complete_turn(self, correlation_id: str, *, reason: str = "completed") -> None: + journal = self._require_run(correlation_id) + if journal.state in TURN_RUN_TERMINAL: + return + if journal.state is TurnRunState.WAITING_TOOL: + self.transition(correlation_id, TurnRunState.RUNNING, reason="flush") + journal = self._require_run(correlation_id) + assert_turn_run_transition(journal.state, TurnRunState.COMPLETED) + self._append_journal(journal, TurnRunState.COMPLETED, reason) + + def fail_turn(self, correlation_id: str, *, reason: str = "failed") -> None: + journal = self._require_run(correlation_id) + if journal.state in TURN_RUN_TERMINAL: + return + assert_turn_run_transition(journal.state, TurnRunState.FAILED) + self._append_journal(journal, TurnRunState.FAILED, reason) + + def mark_recovery(self, correlation_id: str, *, reason: str) -> TurnRunJournal: + journal = self._require_run(correlation_id) + if journal.state not in TURN_RUN_TERMINAL: + assert_turn_run_transition(journal.state, TurnRunState.RECOVERY_REQUIRED) + self._append_journal(journal, TurnRunState.RECOVERY_REQUIRED, reason) + return journal + + def resume_turn(self, correlation_id: str) -> TurnRunJournal: + journal = self._require_run(correlation_id) + if journal.state in TURN_RUN_TERMINAL: + raise HarnessContractError( + f"cannot resume terminal run {journal.state.value}" + ) + return journal + + def list_journal( + self, correlation_id: str, *, offset: int = 0, limit: int = 100 + ) -> List[Dict[str, Any]]: + journal = self._require_run(correlation_id) + return journal.entries[offset : offset + limit] + + def peek_completed_result( + self, + *, + correlation_id: str, + tool_name: str, + payload: Mapping[str, Any], + ) -> Optional[str]: + """Cached IDEMPOTENT result for this (tool, args). Does not bump attempt.""" + digest = _input_digest(tool_name, payload) + ledger_key = f"{correlation_id}:{tool_name}:{digest}" + with self.store.lock: + existing = self.store.invocations.get(ledger_key) + if existing is None: + return None + if existing.idempotency_class is not IdempotencyClass.IDEMPOTENT: + return None + return self.store.invocation_results.get(existing.invocation_id) + + def correlation_for_session(self, session_id: str) -> Optional[str]: + if not session_id: + return None + if self.lease_backend is not None: + held = self.lease_backend.get(session_id) + else: + held = self.store.leases.get(session_id) + if held: + corr = str(held.get("correlation_id") or "") + if corr: + return corr + with self.store.lock: + for corr, journal in self.store.runs.items(): + if journal.caller.session_id == session_id and ( + journal.state not in TURN_RUN_TERMINAL + ): + return corr + return None + + def export_checkpoint(self, correlation_id: str) -> Dict[str, Any]: + journal = self._require_run(correlation_id) + snap = self.store.snapshots.get(journal.snapshot_id) + with self.store.lock: + prefix = f"{correlation_id}:" + invocations: List[Dict[str, Any]] = [] + for key, rec in self.store.invocations.items(): + if not key.startswith(prefix): + continue + rec_map = asdict(rec) + klass = rec.idempotency_class + rec_map["idempotency_class"] = klass.value if klass else None + invocations.append( + { + "ledger_key": key, + "record": rec_map, + "result": self.store.invocation_results.get(rec.invocation_id), + } + ) + outbox = [ + asdict(e) for e in self.store.outbox.get(journal.caller.session_id, []) + ] + snap_map: Optional[Dict[str, Any]] = None + if snap is not None: + snap_map = asdict(snap) + snap_map["caller"] = snap.caller.to_mapping() + return { + "correlation_id": journal.correlation_id, + "state": journal.state.value, + "snapshot_id": journal.snapshot_id, + "interaction_id": journal.interaction_id, + "seq": journal.seq, + "completed_invocation_ids": list(journal.completed_invocation_ids), + "observation_refs": list(journal.observation_refs), + "plan_phase": journal.plan_phase, + "reason": journal.reason, + "entries": list(journal.entries), + "invocations": invocations, + "outbox": outbox, + "caller": journal.caller.to_mapping(), + "snapshot": snap_map, + "worker_id": journal.worker_id, + } + + def import_checkpoint(self, payload: Mapping[str, Any]) -> TurnRunJournal: + caller = native_caller_from_mapping(payload["caller"]) + journal = TurnRunJournal( + correlation_id=str(payload["correlation_id"]), + caller=caller, + state=TurnRunState(str(payload["state"])), + snapshot_id=str(payload.get("snapshot_id") or ""), + interaction_id=str(payload.get("interaction_id") or ""), + seq=int(payload.get("seq") or 0), + worker_id=str(payload.get("worker_id") or self.worker_id), + entries=list(payload.get("entries") or []), + completed_invocation_ids=list( + payload.get("completed_invocation_ids") or [] + ), + observation_refs=list(payload.get("observation_refs") or []), + plan_phase=str(payload.get("plan_phase") or ""), + reason=str(payload.get("reason") or ""), + ) + snap_raw = payload.get("snapshot") + snap: Optional[ToolSurfaceSnapshot] = None + if isinstance(snap_raw, dict) and snap_raw.get("snapshot_id"): + snap = ToolSurfaceSnapshot( + snapshot_id=str(snap_raw["snapshot_id"]), + caller=native_caller_from_mapping(snap_raw["caller"]), + native_tool_names=tuple(snap_raw.get("native_tool_names") or ()), + native_skill_keys=tuple(snap_raw.get("native_skill_keys") or ()), + host_tool_names=tuple(snap_raw.get("host_tool_names") or ()), + host_skill_keys=tuple(snap_raw.get("host_skill_keys") or ()), + created_at=str(snap_raw.get("created_at") or ""), + expires_at=str(snap_raw.get("expires_at") or ""), + revoked=bool(snap_raw.get("revoked")), + ) + with self.store.lock: + self.store.runs[journal.correlation_id] = journal + if journal.interaction_id: + self.store.runs_by_interaction[journal.interaction_id] = ( + journal.correlation_id + ) + if snap is not None: + self.store.snapshots[snap.snapshot_id] = snap + for item in payload.get("invocations") or []: + rec_map = dict(item.get("record") or {}) + klass_raw = rec_map.get("idempotency_class") + rec = InvocationRecord( + invocation_id=str(rec_map["invocation_id"]), + snapshot_id=str(rec_map.get("snapshot_id") or ""), + tool_name=str(rec_map.get("tool_name") or ""), + input_digest=str(rec_map.get("input_digest") or ""), + idempotency_class=( + IdempotencyClass(klass_raw) if klass_raw else None + ), + attempt=int(rec_map.get("attempt") or 1), + outcome=rec_map.get("outcome"), + ) + key = str(item.get("ledger_key") or "") + if key: + self.store.invocations[key] = rec + result = item.get("result") + if result is not None: + self.store.invocation_results[rec.invocation_id] = str(result) + envelopes = [EventEnvelope(**raw) for raw in (payload.get("outbox") or [])] + if envelopes: + self.store.outbox[caller.session_id] = envelopes + return journal + + def persist_to_interaction(self, interaction: Any, correlation_id: str) -> None: + if interaction is None or not correlation_id: + return + if self.get_run(correlation_id) is None: + return + payload = self.export_checkpoint(correlation_id) + metrics = [ + m + for m in list(getattr(interaction, "observability_metrics", None) or []) + if not (isinstance(m, dict) and m.get("kind") == CHECKPOINT_KIND) + ] + metrics.append({"kind": CHECKPOINT_KIND, "payload": payload}) + spans = self.traces_for(correlation_id) + metrics = [ + m + for m in metrics + if not (isinstance(m, dict) and m.get("kind") == TRACE_KIND) + ] + metrics.append({"kind": TRACE_KIND, "spans": spans}) + interaction.observability_metrics = metrics + + def checkpoint_from_interaction(self, interaction: Any) -> Optional[Dict[str, Any]]: + if interaction is None: + return None + for metric in getattr(interaction, "observability_metrics", None) or []: + if isinstance(metric, dict) and metric.get("kind") == CHECKPOINT_KIND: + payload = metric.get("payload") + if isinstance(payload, dict): + return payload + return None + + # -- invocation ledger (HP-05) ---------------------------------------- + + def begin_invocation( + self, + *, + correlation_id: str, + snapshot_id: str, + tool_name: str, + payload: Mapping[str, Any], + idempotency_class: Optional[IdempotencyClass] = None, + ) -> Tuple[InvocationRecord, Optional[str]]: + reject_model_authority_fields(payload) + self.require_usable(snapshot_id) + digest = _input_digest(tool_name, payload) + ledger_key = f"{correlation_id}:{tool_name}:{digest}" + with self.store.lock: + existing = self.store.invocations.get(ledger_key) + if existing is not None: + if existing.idempotency_class is IdempotencyClass.NON_RETRYABLE: + self.mark_recovery( + correlation_id, reason=f"non_retryable:{tool_name}" + ) + raise HarnessContractError( + f"non-retryable tool {tool_name} cannot be replayed" + ) + cached = self.store.invocation_results.get(existing.invocation_id) + retried = replace(existing, attempt=existing.attempt + 1) + self.store.invocations[ledger_key] = retried + reuse = ( + existing.idempotency_class is IdempotencyClass.IDEMPOTENT + and cached is not None + ) + return retried, cached if reuse else None + record = InvocationRecord( + invocation_id=f"inv-{uuid.uuid4().hex}", + snapshot_id=snapshot_id, + tool_name=tool_name, + input_digest=digest, + idempotency_class=idempotency_class, + attempt=1, + ) + self.store.invocations[ledger_key] = record + journal = self.get_run(correlation_id) + if journal is not None and journal.state is TurnRunState.RUNNING: + self.transition(correlation_id, TurnRunState.WAITING_TOOL, reason=tool_name) + self.record_span( + correlation_id, + "tool_invoke", + invocation_id=record.invocation_id, + tool_name=tool_name, + ) + return record, None + + def finish_invocation( + self, + *, + correlation_id: str, + record: InvocationRecord, + result: str, + ok: bool = True, + ) -> None: + clipped = ( + result + if len(result) <= MAX_OBSERVATION_CHARS + else result[:MAX_OBSERVATION_CHARS] + ) + with self.store.lock: + if ok: + self.store.invocation_results[record.invocation_id] = clipped + journal = self.store.runs.get(correlation_id) + if journal is not None: + journal.completed_invocation_ids.append(record.invocation_id) + journal.observation_refs.append(f"inv:{record.invocation_id}") + if not ok and record.idempotency_class is IdempotencyClass.COMPENSATABLE: + if record.tool_name in self._compensators: + self.compensate(record.invocation_id) + if not ok and record.idempotency_class is IdempotencyClass.NON_RETRYABLE: + self.mark_recovery(correlation_id, reason=f"failed:{record.tool_name}") + return + journal = self.get_run(correlation_id) + if journal is not None and journal.state is TurnRunState.WAITING_TOOL: + self.transition( + correlation_id, + TurnRunState.RUNNING, + reason="tool_ok" if ok else "tool_error", + ) + + def compensate(self, invocation_id: str) -> str: + record = None + with self.store.lock: + for rec in self.store.invocations.values(): + if rec.invocation_id == invocation_id: + record = rec + break + if record is None: + raise HarnessContractError(f"unknown invocation {invocation_id}") + fn = self._compensators.get(record.tool_name) + if fn is None: + raise HarnessContractError( + f"no compensator registered for {record.tool_name}" + ) + result = fn(record) + return str(result) + + def register_compensator(self, tool_name: str, fn: Any) -> None: + self._compensators[tool_name] = fn + + # -- outbox (HP-06) --------------------------------------------------- + + def append_event( + self, + *, + session_id: str, + kind: str, + message_id: str, + correlation_id: str, + snapshot_id: str, + payload: Optional[Mapping[str, Any]] = None, + ) -> EventEnvelope: + with self.store.lock: + stream = self.store.outbox.setdefault(session_id, []) + if len(stream) >= MAX_EVENTS_PER_SESSION: + stream.pop(0) + seq = (stream[-1].sequence + 1) if stream else 1 + env = EventEnvelope( + session_id=session_id, + sequence=seq, + cursor=f"{session_id}:{seq}", + message_id=message_id, + correlation_id=correlation_id, + snapshot_id=snapshot_id, + kind=kind, + payload=dict(payload or {}), + ) + stream.append(env) + self.record_span( + correlation_id, + "event_append", + session_id=session_id, + sequence=env.sequence, + ) + return env + + def replay_from( + self, session_id: str, cursor: Optional[str] = None, *, limit: int = 500 + ) -> List[EventEnvelope]: + stream = list(self.store.outbox.get(session_id, [])) + after = 0 + if cursor: + try: + after = int(str(cursor).rsplit(":", 1)[-1]) + except ValueError: + after = 0 + out = [e for e in stream if e.sequence > after] + return out[:limit] + + def journal_entries( + self, correlation_id: str, *, after_seq: int = 0, limit: int = 100 + ) -> List[Dict[str, Any]]: + journal = self.get_run(correlation_id) + if journal is None: + return [] + rows = [e for e in journal.entries if int(e.get("seq") or 0) > after_seq] + return rows[: max(limit, 0)] + + # -- leases (HP-07) --------------------------------------------------- + + def acquire_session_lease( + self, session_id: str, *, ttl_s: float = DEFAULT_LEASE_TTL_S + ) -> None: + if self.lease_backend is not None: + rec = self.lease_backend.acquire(session_id, self.worker_id, ttl_s=ttl_s) + expired = rec.get("expired_correlation_id") or "" + if expired and expired in self.store.runs: + run = self.store.runs[expired] + if run.state not in TURN_RUN_TERMINAL: + self.mark_recovery(expired, reason="lease_expired") + return + now = time.monotonic() + with self.store.lock: + held = self.store.leases.get(session_id) + if ( + held + and held["worker_id"] != self.worker_id + and held["expires_at"] > now + ): + raise SessionBusy(f"session {session_id} leased by {held['worker_id']}") + if ( + held + and held["expires_at"] <= now + and held["worker_id"] != self.worker_id + ): + corr = held.get("correlation_id") + if corr and corr in self.store.runs: + run = self.store.runs[corr] + if run.state not in TURN_RUN_TERMINAL: + run.state = TurnRunState.RECOVERY_REQUIRED + run.reason = "lease_expired" + run.entries.append( + { + "seq": run.seq + 1, + "state": TurnRunState.RECOVERY_REQUIRED.value, + "reason": "lease_expired", + "ts": datetime.now(timezone.utc).isoformat(), + } + ) + run.seq += 1 + self.store.leases[session_id] = { + "worker_id": self.worker_id, + "expires_at": now + ttl_s, + "correlation_id": "", + } + + def bind_lease(self, session_id: str, correlation_id: str) -> None: + if self.lease_backend is not None: + self.lease_backend.bind(session_id, self.worker_id, correlation_id) + return + with self.store.lock: + held = self.store.leases.get(session_id) + if held and held["worker_id"] == self.worker_id: + held["correlation_id"] = correlation_id + + def renew_session_lease( + self, session_id: str, *, ttl_s: float = DEFAULT_LEASE_TTL_S + ) -> None: + if self.lease_backend is not None: + self.lease_backend.renew(session_id, self.worker_id, ttl_s=ttl_s) + return + now = time.monotonic() + with self.store.lock: + held = self.store.leases.get(session_id) + if not held or held["worker_id"] != self.worker_id: + raise SessionBusy(f"session {session_id} not held by {self.worker_id}") + held["expires_at"] = now + ttl_s + + def release_session_lease(self, session_id: str) -> None: + if self.lease_backend is not None: + self.lease_backend.release(session_id, self.worker_id) + return + with self.store.lock: + held = self.store.leases.get(session_id) + if held and held["worker_id"] == self.worker_id: + self.store.leases.pop(session_id, None) + + def drain(self) -> None: + self.store.draining = True + + def worker_lost(self, worker_id: str) -> None: + with self.store.lock: + drop = [ + sid + for sid, held in self.store.leases.items() + if held["worker_id"] == worker_id + ] + for sid in drop: + held = self.store.leases.pop(sid) + corr = held.get("correlation_id") + if corr and corr in self.store.runs: + run = self.store.runs[corr] + if run.state not in TURN_RUN_TERMINAL: + run.state = TurnRunState.RECOVERY_REQUIRED + run.reason = "worker_lost" + run.entries.append( + { + "seq": run.seq + 1, + "state": TurnRunState.RECOVERY_REQUIRED.value, + "reason": "worker_lost", + "ts": datetime.now(timezone.utc).isoformat(), + } + ) + run.seq += 1 + + @property + def is_draining(self) -> bool: + return self.store.draining + + # -- host tools (HP-08) ----------------------------------------------- + + def put_host_tools(self, session_id: str, names: List[str]) -> None: + with self.store.lock: + self.store.host_tools[session_id] = list(names) + + def put_host_skills(self, session_id: str, keys: List[str]) -> None: + with self.store.lock: + self.store.host_skills[session_id] = list(keys) + + def register_host_skill( + self, + session_id: str, + skill_key: str, + *, + digest: str, + spec: str, + body: str, + ) -> None: + """Register the immutable materialization a host exposes for one session.""" + if spec not in ("jv", "claude"): + raise HarnessContractError(f"unsupported host skill spec {spec!r}") + materialization = SkillMaterialization( + skill_key=skill_key, + digest=digest, + spec=spec, + body=body, + ) + with self.store.lock: + keys = self.store.host_skills.setdefault(session_id, []) + if skill_key not in keys: + keys.append(skill_key) + self.store.host_skill_materializations[(session_id, skill_key)] = ( + materialization + ) + + def host_skill_materialization( + self, session_id: str, skill_key: str + ) -> Optional[SkillMaterialization]: + return self.store.host_skill_materializations.get((session_id, skill_key)) + + def revoke_host_tool(self, session_id: str, name: str) -> None: + with self.store.lock: + self.store.revoked_host_tools.setdefault(session_id, set()).add(name) + + def register_host_runner(self, session_id: str, name: str, fn: Any) -> None: + self._host_runners[(session_id, name)] = fn + + def host_runner(self, session_id: str, name: str) -> Any: + return self._host_runners.get((session_id, name)) + + # -- skills (HP-09) --------------------------------------------------- + + def sign_digest(self, digest: str) -> str: + if not self.skill_signing_key: + return "" + return hmac.new( + self.skill_signing_key.encode(), digest.encode(), hashlib.sha256 + ).hexdigest() + + def register_manifest(self, manifest: SkillManifest) -> None: + if manifest.spec not in ("jv", "claude"): + raise HarnessContractError( + f"unsupported skill spec {manifest.spec!r}; only jv and claude" + ) + if manifest.digest in self.store.revoked_manifests: + raise HarnessContractError(f"revoked skill digest {manifest.digest}") + if self.skill_signing_key: + expected = self.sign_digest(manifest.digest) + if not hmac.compare_digest(manifest.signature or "", expected): + raise HarnessContractError("invalid skill signature") + with self.store.lock: + self.store.skill_manifests[manifest.digest] = manifest + + def publish_manifest(self, manifest: SkillManifest) -> None: + self.register_manifest(manifest) + + def revoke_manifest(self, digest: str) -> None: + with self.store.lock: + self.store.revoked_manifests.add(digest) + self.store.skill_manifests.pop(digest, None) + + def activate_skill( + self, + caller: NativeCaller, + snapshot_id: str, + digest: str, + *, + trust_tier: str = "trusted", + ) -> StageRecord: + snap = self.require_usable(snapshot_id) + if digest in self.store.revoked_manifests: + raise HarnessContractError(f"revoked skill digest {digest}") + manifest = self.store.skill_manifests.get(digest) + if manifest is None: + raise HarnessContractError(f"unknown skill digest {digest}") + if trust_tier == "untrusted" and ( + self.isolation_backend not in APPROVED_ISOLATION_BACKENDS + ): + raise SkillIsolationRefused( + "untrusted skill requires an approved isolation backend " + f"(got {self.isolation_backend!r}; subprocess is not a sandbox)" + ) + path = f"stage/{caller.session_id}/{snapshot_id}/{digest}" + rec = StageRecord( + caller=caller, snapshot_id=snapshot_id, digest=digest, path=path + ) + with self.store.lock: + self.store.stages[path] = rec + self.record_span( + snap.snapshot_id, + "skill_activate", + digest=digest, + caller=caller.as_tuple(), + ) + return rec + + def cleanup_stage(self, path: str) -> None: + with self.store.lock: + rec = self.store.stages.get(path) + if rec is not None: + rec.active = False + + # -- traces (HP-10) --------------------------------------------------- + + def record_span(self, correlation_id: str, name: str, **fields: Any) -> None: + if not correlation_id: + return + if "caller" in fields and hasattr(fields["caller"], "as_tuple"): + fields = dict(fields) + fields["caller"] = fields["caller"].as_tuple() + redacted = {k: v for k, v in fields.items() if k not in ("secret", "password")} + _log.info( + "harness.span %s", + json.dumps( + {"correlation_id": correlation_id, "name": name, **redacted}, + default=str, + ), + ) + with self.store.lock: + spans = self.store.traces.setdefault(correlation_id, []) + spans.append( + { + "name": name, + "ts": datetime.now(timezone.utc).isoformat(), + "worker_id": self.worker_id, + **redacted, + } + ) + overflow = len(spans) - self.max_trace_spans + if overflow > 0: + del spans[:overflow] + + def traces_for(self, correlation_id: str) -> List[Dict[str, Any]]: + return list(self.store.traces.get(correlation_id, [])) + + def replay_document(self, correlation_id: str) -> Dict[str, Any]: + journal = self.store.runs.get(correlation_id) + caller = journal.caller.as_tuple() if journal else None + traces = self.traces_for(correlation_id) + for span in traces: + other = span.get("caller") + if other and caller and tuple(other) != caller: + raise HarnessContractError("foreign-user content in trace") + return { + "correlation_id": correlation_id, + "caller": journal.caller.to_mapping() if journal else {}, + "state": journal.state.value if journal else None, + "snapshot_id": journal.snapshot_id if journal else None, + "spans": traces, + "journal": journal.entries if journal else [], + "invocations": list(journal.completed_invocation_ids) if journal else [], + } + + def mark_background(self, correlation_id: str) -> None: + journal = self.get_run(correlation_id) + if journal is None or journal.state in TURN_RUN_TERMINAL: + return + journal.plan_phase = "background" + + def prune_retention(self) -> None: + with self.store.lock: + for sid, stream in list(self.store.outbox.items()): + if len(stream) > MAX_EVENTS_PER_SESSION: + self.store.outbox[sid] = stream[-MAX_EVENTS_PER_SESSION:] + for corr, spans in list(self.store.traces.items()): + if len(spans) > self.max_trace_spans: + self.store.traces[corr] = spans[-self.max_trace_spans :] + + # -- internals -------------------------------------------------------- + + def _require_run(self, correlation_id: str) -> TurnRunJournal: + journal = self.store.runs.get(correlation_id) + if journal is None: + raise HarnessContractError(f"unknown TurnRun {correlation_id}") + return journal + + def _append_journal( + self, journal: TurnRunJournal, dst: TurnRunState, reason: str + ) -> None: + journal.seq += 1 + journal.state = dst + journal.reason = reason + journal.entries.append( + { + "seq": journal.seq, + "state": dst.value, + "reason": reason, + "snapshot_id": journal.snapshot_id, + "correlation_id": journal.correlation_id, + "ts": datetime.now(timezone.utc).isoformat(), + "worker_id": self.worker_id, + } + ) + + +def _input_digest(tool_name: str, payload: Mapping[str, Any]) -> str: + blob = json.dumps( + {"tool": tool_name, "args": dict(payload)}, sort_keys=True, default=str + ) + return hashlib.sha256(blob.encode()).hexdigest()[:16] + + +def get_runtime() -> HarnessRuntime: + global _runtime + with _runtime_guard: + if _runtime is None: + _runtime = HarnessRuntime() + return _runtime + + +def reset_runtime(runtime: Optional[HarnessRuntime] = None) -> HarnessRuntime: + global _runtime + with _runtime_guard: + _runtime = runtime if runtime is not None else HarnessRuntime() + return _runtime + + +def set_runtime(runtime: HarnessRuntime) -> None: + global _runtime + with _runtime_guard: + _runtime = runtime + + +__all__ = [ + "APPROVED_ISOLATION_BACKENDS", + "CHECKPOINT_KIND", + "AdmissionRefused", + "HarnessRuntime", + "HarnessStore", + "MAX_EVENTS_PER_SESSION", + "MAX_OBSERVATION_CHARS", + "MAX_TRACE_SPANS", + "TRACE_KIND", + "SAME_SESSION_POLICY", + "SessionBusy", + "SkillIsolationRefused", + "SkillManifest", + "StageRecord", + "TurnRunJournal", + "get_runtime", + "reset_runtime", + "set_runtime", +] diff --git a/jvagent/memory/manager.py b/jvagent/memory/manager.py index 5726b1d3..817960cb 100644 --- a/jvagent/memory/manager.py +++ b/jvagent/memory/manager.py @@ -88,12 +88,14 @@ async def get_user( Returns: User node if found or created, None otherwise """ + from jvagent.core.distributed_lease import distributed_lease from jvagent.memory.lock_manager import get_user_lock_manager - lock_mgr = get_user_lock_manager() - lock = await lock_mgr.acquire(f"{self.id}:{user_id}") - async with lock: - return await self._get_user_unlocked(user_id, create_if_missing) + async with distributed_lease(f"user-create:{self.id}:{user_id}"): + lock_mgr = get_user_lock_manager() + lock = await lock_mgr.acquire(f"{self.id}:{user_id}") + async with lock: + return await self._get_user_unlocked(user_id, create_if_missing) async def _get_user_unlocked( self, user_id: str, create_if_missing: bool @@ -519,14 +521,16 @@ async def get_session( return await self._get_session_unlocked( user_id, session_id, user_name, channel ) + from jvagent.core.distributed_lease import distributed_lease from jvagent.memory.lock_manager import get_conversation_lock_manager - lock_mgr = get_conversation_lock_manager() - lock = await lock_mgr.acquire(f"session-create:{self.id}:{session_id}") - async with lock: - return await self._get_session_unlocked( - user_id, session_id, user_name, channel - ) + async with distributed_lease(f"session-create:{self.id}:{session_id}"): + lock_mgr = get_conversation_lock_manager() + lock = await lock_mgr.acquire(f"session-create:{self.id}:{session_id}") + async with lock: + return await self._get_session_unlocked( + user_id, session_id, user_name, channel + ) async def _get_session_unlocked( self, diff --git a/jvagent/scaffold/skill_resolve.py b/jvagent/scaffold/skill_resolve.py index efc37503..1ab357c8 100644 --- a/jvagent/scaffold/skill_resolve.py +++ b/jvagent/scaffold/skill_resolve.py @@ -3,6 +3,7 @@ from __future__ import annotations import fnmatch +import hashlib import importlib import logging import os @@ -24,6 +25,14 @@ SELECTOR_ALL = "-all" +def skill_digest(skill_dir: Union[str, Path]) -> str: + """SHA-256 prefix of ``SKILL.md`` (or the file itself). Empty if missing.""" + path = Path(skill_dir) + skill_md = path / "SKILL.md" if path.is_dir() else path + blob = skill_md.read_bytes() if skill_md.is_file() else b"" + return hashlib.sha256(blob).hexdigest()[:16] + + _KNOWN_FRONTMATTER_KEYS = frozenset( { "allowed-channels", @@ -439,6 +448,7 @@ def parse_skill_bundle( "deny_access_directive": deny_access_directive, "scope_hint": scope_hint, "source": source, + "digest": skill_digest(skill_file), "metadata": { "version": frontmatter.get("version"), "license": frontmatter.get("license"), diff --git a/jvagent/skills/artifact_handler/SKILL.md b/jvagent/skills/artifact_handler/SKILL.md index 08495540..9fc7171f 100644 --- a/jvagent/skills/artifact_handler/SKILL.md +++ b/jvagent/skills/artifact_handler/SKILL.md @@ -22,6 +22,7 @@ allowed-tools: - artifact_handler__delete_document - artifact_handler__check_ingest_status - artifact_handler__check_pending_attachments + - pageindex__search tags: - vault - ingest @@ -40,14 +41,15 @@ tags: Do **not** use `check_pending_attachments` to answer ready/finished questions. - **"Is it ready/done/finished?"** → always `check_ingest_status` with **no arguments** (never pass `doc_name` or `url`; never use - `check_pending_attachments`). If `ready` and a job has `pending_question` - (or the conversation deferred a content question), call `pageindex__search` - with `query` = that question and `doc_name` = `jobs[].doc_name` for the - matching ready doc, then write one reply: (1) ready, (2) remind the - question, (3) answer. If `ready` without a deferred question, tell the user - warmly and invite questions. If `queued`, say you're still processing and - will follow up. **Do not** call `ingest_document` again for a URL that is - already queued or ready. + `check_pending_attachments`). If `ready` and a job has `pending_question`, + deliver the tool's `Tell the user:` ready → remind → answer directive; do + not stop after acknowledging ready. If the tool instead asks you to search, + call `pageindex__search` with `query` = that question and `doc_name` = + `jobs[].doc_name` for the matching ready doc, then write one reply: (1) + ready, (2) remind the question, (3) answer. If `ready` without a deferred + question, tell the user warmly and invite questions. If `queued`, say + you're still processing and will follow up. **Do not** call + `ingest_document` again for a URL that is already queued or ready. - **"List my documents"** → `list_my_documents`. - **"Delete/remove/clean up"** → `delete_document` on explicit yes (expired docs are surfaced automatically by other vault tools). @@ -80,14 +82,16 @@ tags: `pageindex__search` with `query` (the user's question) and `doc_name` when known. If unknown, follow faq document-selection rules (description match before clarifying which file). -3. **When `check_ingest_status` returns `ready` with a `pending_question`, write - one reply in this order:** (1) say the document/image is ready, (2) remind - them of their pending question (quote/paraphrase), (3) give the answer from - `pageindex__search` scoped to that job's `doc_name`. That `doc_name` becomes - the Active document for follow-ups. If ready with no pending question but - the conversation deferred a content question, search with the matching - `jobs[].doc_name` and answer. If ready with nothing deferred, just say it's - ready and invite questions. +3. **When `check_ingest_status` returns `ready` with a `pending_question`, + deliver the generated reply in this order:** (1) say the document/image is + ready, (2) remind them of their pending question (quote/paraphrase), (3) + give the answer. Prefer the tool's `Tell the user:` directive when present. + If the tool asks you to search instead, call `pageindex__search` scoped to + that job's `doc_name` and then write that same three-part reply. That + `doc_name` becomes the Active document for follow-ups. If ready with no + pending question but the conversation deferred a content question, search + with the matching `jobs[].doc_name` and answer. If ready with nothing + deferred, just say it's ready and invite questions. 4. **Never claim a doc is searchable before `check_ingest_status` confirms ready.** Queued ≠ searchable. 5. **Never delete without an explicit yes.** diff --git a/jvagent/skills/artifact_handler/scripts/custom_tools.py b/jvagent/skills/artifact_handler/scripts/custom_tools.py index 6280e262..05e2a890 100644 --- a/jvagent/skills/artifact_handler/scripts/custom_tools.py +++ b/jvagent/skills/artifact_handler/scripts/custom_tools.py @@ -20,9 +20,10 @@ The LLM-facing entry point is ``ingest_document``: the model calls it when the user types a URL. An optional ``question`` is saved with the job and -answered when the document is ready (WhatsApp notify generates the reply -in-process via PageIndex search + call_model, or web via -``check_ingest_status``). +answered when the document is ready (WhatsApp/Messenger notify generates the +reply in-process; other channels generate the same ready → remind → answer +copy inside ``check_ingest_status`` when the user asks if processing is +finished). Access is always ``private_``; the matching access-control group is created idempotently so the same user can later search their own docs. @@ -990,6 +991,7 @@ async def ingest_document(ctx) -> Dict[str, Any]: "notified": False, "job_id": job_id or None, "status": "queued", + "file_url": url_arg, } if pending_q: entry["pending_question"] = pending_q @@ -1000,6 +1002,7 @@ async def ingest_document(ctx) -> Dict[str, Any]: "doc_name": doc_name, "status": "queued", "submitted_at": now, + "file_url": url_arg, } if pending_q: pending_entry["pending_question"] = pending_q @@ -1059,6 +1062,23 @@ async def ingest_document(ctx) -> Dict[str, Any]: await conversation.update_context({_VAULT_CTX_KEY: vault}) except Exception: pass + from jvagent.action.artifact_handler_interact_action.vault_events import ( + record_vault_event, + saved_document_event, + ) + + for name in queued: + await record_vault_event( + visitor, + saved_document_event( + name, pending_question=pending_q, status="processing" + ), + ) + for name in ingested: + await record_vault_event( + visitor, + saved_document_event(name, pending_question=pending_q, status="ready"), + ) # ── Reply ── if queued and not failed: @@ -1476,6 +1496,160 @@ async def _clear_pending_questions( return updated +def _tell_user_directive(text: str) -> str: + """Prefix generated copy so the orchestrator delivers it as the turn reply.""" + body = (text or "").strip() + if not body: + return "" + if body.lower().startswith("tell the user"): + return body + return f"Tell the user: {body}" + + +def _pending_search_say(*, partial: bool) -> str: + """Fallback instruction when in-process ready+answer generation fails.""" + scope = " on a ready doc" if partial else "" + ready_bit = ( + "say which document(s) are ready" + if partial + else "say the document/image is ready" + ) + return ( + f"For each pending question{scope}, call pageindex__search with query " + "set to the pending_question value and doc_name set to that " + "job's doc_name. Then write one reply in this exact order: " + f"(1) {ready_bit}, (2) remind them of their " + "pending question by quoting or paraphrasing it, (3) give the " + "answer from the search results." + ) + + +def _pending_search_compose_directive( + ready_questions: List[Dict[str, str]], + *, + became: List[str], + still: List[str], +) -> str: + questions_text = "; ".join(q["question"] for q in ready_questions) + if became and not still: + if len(became) == 1: + phrase = _friendly_file_phrase(became[0]) + return ( + f"In one reply: (1) tell the user {phrase} is ready, " + f"(2) remind them they asked: {questions_text}, " + f"(3) answer that question from pageindex__search results." + ) + phrases = [_friendly_file_phrase(n) for n in became] + return ( + f"In one reply: (1) tell the user their files are ready " + f"({', '.join(phrases)}), " + f"(2) remind them they asked about: {questions_text}, " + f"(3) answer from pageindex__search results." + ) + if still: + ready_phrases = [_friendly_file_phrase(n) for n in became] if became else [] + ready_bit = ( + f"some of their files are ready ({', '.join(ready_phrases)}) " + "while others are still processing" + if ready_phrases + else "some of their files are ready while others are still processing" + ) + return ( + f"In one reply: (1) tell the user {ready_bit}, " + f"(2) remind them they asked about: {questions_text}, " + f"(3) answer the ready docs from pageindex__search results." + ) + return ( + f"In one reply: (1) tell the user their saved files are ready, " + f"(2) remind them they asked about: {questions_text}, " + f"(3) answer from pageindex__search results." + ) + + +def _emit_pending_question_search_fallback( + ctx: Any, + ready_questions: List[Dict[str, str]], + *, + became: List[str], + still: List[str], +) -> None: + ctx.add_directive( + _pending_search_compose_directive(ready_questions, became=became, still=still) + ) + ctx.say(_pending_search_say(partial=bool(still))) + + +async def _resolve_ready_message_agent(ctx: Any) -> Tuple[Any, Any]: + """Return ``(agent, vault_action)`` for in-process ready-message generation.""" + vault_action = getattr(ctx, "_action", None) + if vault_action is None: + vault_action = await _get_artifact_handler_action(ctx) + agent = None + if vault_action is not None: + getter = getattr(vault_action, "get_agent", None) + if callable(getter): + try: + agent = await getter() + except Exception: + agent = None + if agent is None: + visitor = getattr(ctx, "visitor", None) + agent = getattr(visitor, "_agent", None) if visitor is not None else None + return agent, vault_action + + +async def _generate_poll_ready_answer( + ctx: Any, + ready_questions: List[Dict[str, str]], + desc_lookup: Dict[str, str], +) -> Optional[str]: + """Generate ready → remind → answer copy for other-channel status polls.""" + if not ready_questions: + return None + agent, vault_action = await _resolve_ready_message_agent(ctx) + if agent is None or vault_action is None: + return None + from jvagent.action.artifact_handler_interact_action.ready_message import ( + _generate_ready_message, + _generate_ready_message_multi, + ) + + try: + if len(ready_questions) == 1: + q = ready_questions[0] + doc_name = q["doc_name"] + return await _generate_ready_message( + agent=agent, + vault_action=vault_action, + internal_doc_name=doc_name, + display_doc=_display_doc_name(doc_name), + utterance=q["question"], + doc_description=desc_lookup.get(doc_name) or None, + ) + entries = [ + { + "internal_doc_name": q["doc_name"], + "display_doc": _display_doc_name(q["doc_name"]), + "pending_question": q["question"], + } + for q in ready_questions + ] + descriptions: Dict[str, str] = {} + for q in ready_questions: + display = _display_doc_name(q["doc_name"]) + desc = desc_lookup.get(q["doc_name"], "") + if desc: + descriptions[display] = desc + return await _generate_ready_message_multi( + agent=agent, + vault_action=vault_action, + ready_entries=entries, + doc_descriptions=descriptions or None, + ) + except Exception: + return None + + async def check_ingest_status(ctx) -> Dict[str, Any]: """Check pending async ingest jobs and report ready / still-processing. @@ -1492,10 +1666,11 @@ async def check_ingest_status(ctx) -> Dict[str, Any]: Also auto-runs (via helpers) on ingest_document / list_my_documents / review_expired activation. - When a ready job has a saved ``pending_question``, surface it so the - agent replies in one message: ready notice → remind the question → - answer via faq / pageindex__search using that job's ``doc_name`` (never - invent a Google Docs/URL id or other non-vault id as ``doc_name``). + When a ready job has a saved ``pending_question``, generate the answer + in-process (ready notice → remind the question → answer from PageIndex + content) and return it as a ``Tell the user:`` directive. If generation + fails, fall back to ``pageindex__search`` using that job's ``doc_name`` + (never invent a Google Docs/URL id or other non-vault id as ``doc_name``). """ visitor = ctx.visitor session_id = _resolve_session_id(visitor) @@ -1537,40 +1712,16 @@ async def check_ingest_status(ctx) -> Dict[str, Any]: ready_questions = _collect_ready_pending_questions(pending) if became and not still and not failed: - if ready_questions: - questions_text = "; ".join(q["question"] for q in ready_questions) + if not ready_questions: if len(became) == 1: phrase = _friendly_file_phrase(became[0]) ctx.add_directive( - f"In one reply: (1) tell the user {phrase} is ready, " - f"(2) remind them they asked: {questions_text}, " - f"(3) answer that question from pageindex__search results." + f"Tell the user {phrase} is ready and they can ask questions about it." ) else: - phrases = [_friendly_file_phrase(n) for n in became] ctx.add_directive( - f"In one reply: (1) tell the user their files are ready " - f"({', '.join(phrases)}), " - f"(2) remind them they asked about: {questions_text}, " - f"(3) answer from pageindex__search results." + "Tell the user their files are ready and they can ask questions about them." ) - ctx.say( - "For each pending question, call pageindex__search with query " - "set to the pending_question value and doc_name set to that " - "job's doc_name. Then write one reply in this exact order: " - "(1) say the document/image is ready, (2) remind them of their " - "pending question by quoting or paraphrasing it, (3) give the " - "answer from the search results." - ) - elif len(became) == 1: - phrase = _friendly_file_phrase(became[0]) - ctx.add_directive( - f"Tell the user {phrase} is ready and they can ask questions about it." - ) - else: - ctx.add_directive( - "Tell the user their files are ready and they can ask questions about them." - ) status = "ready" elif still and not became and not failed: if len(still) == 1: @@ -1586,24 +1737,7 @@ async def check_ingest_status(ctx) -> Dict[str, Any]: ) status = "queued" elif became and still: - if ready_questions: - questions_text = "; ".join(q["question"] for q in ready_questions) - ready_phrases = [_friendly_file_phrase(n) for n in became] - ctx.add_directive( - f"In one reply: (1) tell the user some of their files are ready " - f"({', '.join(ready_phrases)}) while others are still processing, " - f"(2) remind them they asked about: {questions_text}, " - f"(3) answer the ready docs from pageindex__search results." - ) - ctx.say( - "For each pending question on a ready doc, call pageindex__search " - "with query set to the pending_question value and doc_name set to " - "that job's doc_name. Then write one reply in this exact order: " - "(1) say which document(s) are ready, (2) remind them of their " - "pending question by quoting or paraphrasing it, (3) give the " - "answer from the search results." - ) - else: + if not ready_questions: ready_phrases = [_friendly_file_phrase(n) for n in became] ctx.add_directive( f"Tell the user some of their files are ready " @@ -1619,39 +1753,9 @@ async def check_ingest_status(ctx) -> Dict[str, Any]: else: # Mixed failed + other, or only ready-from-before. if ready_questions and still: - questions_text = "; ".join(q["question"] for q in ready_questions) - ctx.add_directive( - f"In one reply: (1) tell the user some of their files are ready " - f"while others are still processing, " - f"(2) remind them they asked about: {questions_text}, " - f"(3) answer the ready docs from pageindex__search results." - ) - ctx.say( - "For each pending question on a ready doc, call pageindex__search " - "with query set to the pending_question value and doc_name set to " - "that job's doc_name. Then write one reply in this exact order: " - "(1) say which document(s) are ready, (2) remind them of their " - "pending question by quoting or paraphrasing it, (3) give the " - "answer from the search results." - ) status = "partial" elif ready_names and not still: - if ready_questions: - questions_text = "; ".join(q["question"] for q in ready_questions) - ctx.add_directive( - f"In one reply: (1) tell the user their saved files are ready, " - f"(2) remind them they asked about: {questions_text}, " - f"(3) answer from pageindex__search results." - ) - ctx.say( - "For each pending question, call pageindex__search with query " - "set to the pending_question value and doc_name set to that " - "job's doc_name. Then write one reply in this exact order: " - "(1) say the document/image is ready, (2) remind them of their " - "pending question by quoting or paraphrasing it, (3) give the " - "answer from the search results." - ) - else: + if not ready_questions: ctx.add_directive( "Tell the user their saved files are ready and they can ask " "questions about them." @@ -1687,11 +1791,37 @@ async def check_ingest_status(ctx) -> Dict[str, Any]: } ) - # Only clear deferred questions after we surfaced them for answering. + answered = False if ready_questions and status in ("ready", "partial"): - pending = await _clear_pending_questions( - conversation, group_key, pending, ready_questions + answer_text = await _generate_poll_ready_answer( + ctx, ready_questions, desc_lookup ) + if answer_text: + tell = _tell_user_directive(answer_text) + ctx.say(tell) + ctx.add_directive(tell) + pending = await _clear_pending_questions( + conversation, group_key, pending, ready_questions + ) + answered_docs = {q["doc_name"] for q in ready_questions} + for job in jobs_list: + if job.get("doc_name") in answered_docs: + job["pending_question"] = None + answered = True + from jvagent.action.artifact_handler_interact_action.vault_events import ( + answered_pending_event, + record_vault_event, + ) + + for q in ready_questions: + await record_vault_event( + visitor, + answered_pending_event(q["doc_name"], q["question"]), + ) + else: + _emit_pending_question_search_fallback( + ctx, ready_questions, became=became, still=still + ) elif ready_questions: # Still processing only — keep questions for a later status check. ready_questions = [] @@ -1724,7 +1854,17 @@ async def check_ingest_status(ctx) -> Dict[str, Any]: "Active document for follow-ups; for later vague questions, prefer a " "clearer doc_description match over Active document when they conflict." ) - if ready_questions: + if answered: + system_message += ( + " A pending question was answered in this result. Deliver the " + "response_directive to the user as the reply. Do not call " + "pageindex__search for that question." + ) + if active_candidate: + system_message += ( + f" After answering, Active document is {active_candidate!r}." + ) + elif ready_questions: system_message += ( " One or more jobs have a pending_question field. Call " "pageindex__search with query set to the pending_question value and " @@ -1751,9 +1891,6 @@ async def check_ingest_status(ctx) -> Dict[str, Any]: "not re-ingest." ) - # When everything is ready (or nothing pending), release task-lock so faq - # can answer content questions on the next turn. - return ctx.tool_response( ok=True, status=status, diff --git a/jvagent/tooling/tool.py b/jvagent/tooling/tool.py index 2aada06e..69b89619 100644 --- a/jvagent/tooling/tool.py +++ b/jvagent/tooling/tool.py @@ -28,6 +28,7 @@ class Tool: access_label: Optional[str] = None terminal: Optional[bool] = None binds_visitor: Optional[bool] = None + idempotency_class: Optional[Any] = None def __post_init__(self) -> None: if not self.parameters_schema: diff --git a/jvagent/tooling/tool_decorator.py b/jvagent/tooling/tool_decorator.py index 29982c5a..4a06ac59 100644 --- a/jvagent/tooling/tool_decorator.py +++ b/jvagent/tooling/tool_decorator.py @@ -36,6 +36,7 @@ async def fetch(self, url: Annotated[str, "The http(s) URL to fetch."]) -> str: from dataclasses import dataclass from typing import Any, Callable, Dict, List, Optional, Tuple +from jvagent.harness.contracts import IdempotencyClass from jvagent.tooling.signature_schema import build_parameters_schema from jvagent.tooling.tool import Tool @@ -61,6 +62,7 @@ class ToolSpec: access_label: Optional[str] = None terminal: Optional[bool] = None binds_visitor: Optional[bool] = None + idempotency_class: Optional[IdempotencyClass] = None def tool( @@ -71,6 +73,7 @@ def tool( access_label: Optional[str] = None, terminal: Optional[bool] = None, binds_visitor: Optional[bool] = None, + idempotency_class: Optional[IdempotencyClass] = None, ) -> Callable[..., Any]: """Mark a method as an agent tool. Usable as ``@tool`` or ``@tool(name=...)``.""" @@ -80,6 +83,7 @@ def tool( access_label=access_label, terminal=terminal, binds_visitor=binds_visitor, + idempotency_class=idempotency_class, ) def decorate(fn: Callable[..., Any]) -> Callable[..., Any]: @@ -209,6 +213,8 @@ def collect_tools(instance: Any) -> List[Tool]: built.terminal = spec.terminal if spec.binds_visitor is not None: built.binds_visitor = spec.binds_visitor + if spec.idempotency_class is not None: + built.idempotency_class = spec.idempotency_class tools.append(built) tools.sort(key=lambda t: t.name) diff --git a/jvchat/src/components/DebugInteractions.tsx b/jvchat/src/components/DebugInteractions.tsx index b0df72b7..7abc883f 100644 --- a/jvchat/src/components/DebugInteractions.tsx +++ b/jvchat/src/components/DebugInteractions.tsx @@ -28,6 +28,18 @@ import { toolCallsForMetric, type DebugToolCall, } from "../lib/debugToolCalls"; +import { + buildExportV2, + buildQueryPayload, + buildReplaySnapshot, + formatCopyPrompt, + formatImproveSystemPrompt, + normalizeLiteLLMModelId, + parseImportFile, + unwrapQueryActionResponse, + type DebugExportSelection, + type ReplaySnapshot, +} from "../lib/debugReplay"; /** Code / text fields: black in dark theme, off-grey in light theme */ function debugCodePanelClass(isDark: boolean) { @@ -36,23 +48,6 @@ function debugCodePanelClass(isDark: boolean) { : "bg-zinc-100 border border-zinc-300 text-zinc-900 placeholder-zinc-600"; } -function parseJsonArray( - text: string, - label: string, -): { ok: true; value: unknown[] } | { ok: false; error: string } { - const trimmed = text.trim(); - if (!trimmed) return { ok: true, value: [] }; - try { - const parsed = JSON.parse(trimmed); - if (!Array.isArray(parsed)) { - return { ok: false, error: `Cannot retest: ${label} must be an array.` }; - } - return { ok: true, value: parsed }; - } catch { - return { ok: false, error: `Cannot retest: ${label} is not valid JSON.` }; - } -} - /** * Build a human-readable label for an observability metric. * @@ -61,7 +56,14 @@ function parseJsonArray( * type-specific summary, helm_shift events render as bare "Interaction" * rows that the inspector can't fill — produces dead-air gaps in the UI. */ +function isHarnessJournalMetric(metric: any): boolean { + return typeof metric?.kind === "string" && metric.kind.startsWith("harness."); +} + function formatMetricLabel(metric: any): string { + if (isHarnessJournalMetric(metric)) { + return String(metric.kind); + } const data = metric?.data || {}; const eventType = metric?.event_type || ""; if (eventType === "helm_shift") { @@ -244,6 +246,8 @@ export function DebugInteractions({ /** Editable tool definitions sent on retest. */ const [toolsText, setToolsText] = useState("[]"); const replaySyncKeyRef = useRef(""); + const pendingImportSelectionRef = useRef(null); + const skipEditorSyncRef = useRef(false); const [improveInstruction, setImproveInstruction] = useState(""); const [improveModel, setImproveModel] = useState("gpt-4o"); const [improving, setImproving] = useState(false); @@ -284,6 +288,7 @@ export function DebugInteractions({ // → data.provider). Falls back to the first available provider if the // recorded one isn't installed on this agent. useEffect(() => { + if (skipEditorSyncRef.current) return; if (!selectedInteraction) return; if (selectedInteraction.event_type !== "model_call") return; const metricProvider = selectedInteraction.data?.provider; @@ -357,9 +362,12 @@ export function DebugInteractions({ const pd = metric.data || {}; // Get history from metric data or parent's conversation history const history = pd.history || parent.conversationHistory || []; + const metricKey = `${parent.id}:${metricIdx}:${metric.timestamp ?? ""}`; setSelectedInteraction({ - id: metric.id, + id: metric.id || metricKey, + parentId: parent.id, + metricIndex: metricIdx, // ADR-0009 / observability: every metric carries event_type + // data. Surface both so the inspector can render type-specific // payloads (helm_shift, model_call, etc.) rather than treating @@ -380,6 +388,15 @@ export function DebugInteractions({ tool_names: Array.isArray(pd.tool_names) ? pd.tool_names : [], tool_calls: Array.isArray(pd.tool_calls) ? pd.tool_calls : [], finish_reason: pd.finish_reason || "", + called_by: pd.called_by || "", + usage: pd.usage || null, + temperature: typeof pd.temperature === "number" ? pd.temperature : undefined, + max_tokens: typeof pd.max_tokens === "number" ? pd.max_tokens : undefined, + tool_choice: pd.tool_choice, + parallel_tool_calls: + typeof pd.parallel_tool_calls === "boolean" + ? pd.parallel_tool_calls + : undefined, }, }); setTestResult(null); @@ -402,7 +419,9 @@ export function DebugInteractions({ return (logs || []) .map((log: any) => { const interactionData = log.log_data?.interaction_data || {}; - const metrics = interactionData.observability_metrics || []; + const metrics = (interactionData.observability_metrics || []).filter( + (m: any) => !isHarnessJournalMetric(m), + ); const utterance = interactionData.utterance; const conversationHistory = interactionData.conversation_history || []; @@ -703,15 +722,18 @@ export function DebugInteractions({ } const currentParent = selectedParentIndex != null ? effectiveParents[selectedParentIndex] : null; - const metricId = selectedInteraction?.id; - const parentWithMetric = metricId - ? effectiveParents.find((p) => - p.metrics?.some((m: any) => m.id === metricId), - ) + const parentId = selectedInteraction?.parentId; + const storedMetricIdx = selectedInteraction?.metricIndex; + const parentWithMetric = parentId + ? effectiveParents.find((p) => p.id === parentId) : null; const metricIdx = - parentWithMetric?.metrics?.findIndex((m: any) => m.id === metricId) ?? -1; - if (parentWithMetric && metricIdx >= 0) { + typeof storedMetricIdx === "number" ? storedMetricIdx : -1; + if ( + parentWithMetric && + metricIdx >= 0 && + metricIdx < (parentWithMetric.metrics?.length || 0) + ) { const newParentIdx = effectiveParents.indexOf(parentWithMetric); if (newParentIdx !== selectedParentIndex || selectedMetricIndex !== metricIdx) { selectInteraction(newParentIdx, metricIdx, effectiveParents); @@ -733,6 +755,10 @@ export function DebugInteractions({ }, [selectedUserId, pageSize, refreshInteractionLogsPage1]); useEffect(() => { + if (skipEditorSyncRef.current) { + setShowHistory(true); + return; + } const historyData = selectedInteraction?.data?.history; const hasHistory = Array.isArray(historyData); @@ -770,6 +796,7 @@ export function DebugInteractions({ })(), ].join(":"); useEffect(() => { + if (skipEditorSyncRef.current) return; if (replaySyncKey === replaySyncKeyRef.current) return; replaySyncKeyRef.current = replaySyncKey; setReplayText( @@ -798,6 +825,103 @@ export function DebugInteractions({ adjustHeight(improveResultRef.current); }, [improveResult, loading]); + useEffect(() => { + const sel = pendingImportSelectionRef.current; + if (sel && selectedInteraction) { + pendingImportSelectionRef.current = null; + replaySyncKeyRef.current = replaySyncKey; + let parsedHistory = Array.isArray(selectedInteraction.data?.history) + ? selectedInteraction.data.history + : []; + try { + const parsed = sel.historyText.trim() + ? JSON.parse(sel.historyText) + : []; + if (Array.isArray(parsed)) parsedHistory = parsed; + } catch { + // Keep metric history when the exported editor JSON is invalid. + } + setHistoryText(sel.historyText); + setReplayText(sel.replayText); + setToolsText(sel.toolsText); + if (sel.replayModel) setReplayModel(sel.replayModel); + if (sel.provider && modelActions[sel.provider]) { + setSelectedProvider(sel.provider); + setModelAction(modelActions[sel.provider]); + } + setTestResult(sel.testResult ?? null); + setSelectedInteraction((si: any) => + si + ? { + ...si, + data: { + ...si.data, + user_prompt: sel.user_prompt, + system_prompt: sel.system_prompt, + history: parsedHistory, + }, + } + : si, + ); + return; + } + if (skipEditorSyncRef.current) { + skipEditorSyncRef.current = false; + } + }, [selectedInteraction, replaySyncKey, modelActions]); + + const liveSnapshot = useCallback((): + | { ok: true; snapshot: ReplaySnapshot } + | { ok: false; error: string } => { + if (!selectedInteraction) { + return { ok: false, error: "Cannot retest: no interaction selected." }; + } + return buildReplaySnapshot({ + user: selectedInteraction.data.user_prompt || "", + system: selectedInteraction.data.system_prompt || "", + historyText, + replayText, + toolsText, + model: (replayModel || "").trim(), + provider: selectedProvider || "", + response: selectedInteraction.data.response || "", + toolCalls: selectedInteraction.data.tool_calls, + finishReason: selectedInteraction.data.finish_reason || "", + calledBy: selectedInteraction.data.called_by, + usage: selectedInteraction.data.usage, + toolSource: retestTools.source, + temperature: selectedInteraction.data.temperature, + maxTokens: selectedInteraction.data.max_tokens, + toolChoice: selectedInteraction.data.tool_choice, + parallelToolCalls: selectedInteraction.data.parallel_tool_calls, + }); + }, [ + selectedInteraction, + historyText, + replayText, + toolsText, + replayModel, + selectedProvider, + retestTools.source, + ]); + + const copyLivePrompt = async (withImprove: boolean) => { + const built = liveSnapshot(); + if (!built.ok) { + setError(built.error); + return; + } + const text = formatCopyPrompt( + built.snapshot, + withImprove ? improveInstruction : undefined, + ); + try { + await navigator.clipboard.writeText(text); + } catch { + setError("Could not copy to clipboard."); + } + }; + const handleTest = async () => { if (!selectedInteraction) return; @@ -813,8 +937,16 @@ export function DebugInteractions({ return; } - const prompt = (selectedInteraction.data.user_prompt || "").trim(); - if (!prompt) { + const built = liveSnapshot(); + if (!built.ok) { + preserveScroll(() => + setTestResult({ success: false, error: built.error }), + ); + return; + } + const snapshot = built.snapshot; + + if (!(snapshot.user || "").trim()) { preserveScroll(() => setTestResult({ success: false, @@ -828,15 +960,7 @@ export function DebugInteractions({ const finishReason = selectedInteraction.data.finish_reason || ""; const needsTools = originalToolCalls.length > 0 || finishReason === "tool_calls"; - - const parsedTools = parseJsonArray(toolsText, "Tools (JSON)"); - if (!parsedTools.ok) { - preserveScroll(() => - setTestResult({ success: false, error: parsedTools.error }), - ); - return; - } - if (needsTools && parsedTools.value.length === 0) { + if (needsTools && snapshot.tools.length === 0) { preserveScroll(() => setTestResult({ success: false, @@ -846,10 +970,8 @@ export function DebugInteractions({ ); return; } - const tools = parsedTools.value; - const modelToSend = (replayModel || "").trim(); - if (!modelToSend) { + if (!snapshot.model) { preserveScroll(() => setTestResult({ success: false, @@ -860,68 +982,21 @@ export function DebugInteractions({ return; } - const parsedHistory = parseJsonArray(historyText, "History (JSON)"); - if (!parsedHistory.ok) { - preserveScroll(() => - setTestResult({ success: false, error: parsedHistory.error }), - ); - return; - } - const history = parsedHistory.value; - - const parsedReplay = parseJsonArray(replayText, "This-turn tool replay (JSON)"); - if (!parsedReplay.ok) { - preserveScroll(() => - setTestResult({ success: false, error: parsedReplay.error }), - ); - return; - } - const replay = parsedReplay.value; - preserveScroll(() => { setTesting(true); setTestResult(null); }); - const includeTools = tools.length > 0 && (needsTools || retestTools.source === "recorded"); - try { - const payload: Record = { - model: modelToSend, - provider: selectedProvider || undefined, - }; - if (replay.length > 0 || includeTools) { - const messages: Record[] = []; - const system = selectedInteraction.data.system_prompt; - if (system) { - messages.push({ role: "system", content: system }); - } - if (history.length > 0) { - messages.push(...(history as Record[])); - } - messages.push({ - role: "user", - content: selectedInteraction.data.user_prompt, - }); - messages.push(...(replay as Record[])); - payload.messages = messages; - payload.tool_choice = "auto"; - payload.parallel_tool_calls = false; - if (includeTools) { - payload.tools = tools; - } - } else { - payload.prompt = selectedInteraction.data.user_prompt; - payload.system = selectedInteraction.data.system_prompt; - payload.history = history; - } - - const data = await apiClient.queryAction(actionId, payload); + const payload = buildQueryPayload(snapshot); + const data = unwrapQueryActionResponse( + await apiClient.queryAction(actionId, payload), + ); preserveScroll(() => setTestResult({ success: true, response: data.response, - data: data, + data, }), ); } catch (error: any) { @@ -939,6 +1014,12 @@ export function DebugInteractions({ const handleImprovePrompt = async () => { if (!selectedInteraction || !modelAction || !improveInstruction) return; + const built = liveSnapshot(); + if (!built.ok) { + preserveScroll(() => setImproveResult(`Error: ${built.error}`)); + return; + } + preserveScroll(() => { setImproving(true); setImproveResult(""); @@ -946,35 +1027,21 @@ export function DebugInteractions({ try { const improvePayload = { - prompt: `Given the following context, improve the prompts based on the instruction. - -User Prompt: -${selectedInteraction.data.user_prompt} - -System Prompt: -${selectedInteraction.data.system_prompt} - -Conversation History: -${JSON.stringify(selectedInteraction.data.history || [], null, 2)} - -RESULT: -${selectedInteraction.data.response} - -Improvement Instruction: -${improveInstruction} - -Provide improvement instruction on how to improve the prompt. Return a raw markdown.`, - system: - "You are a prompt engineering expert. Analyze the given prompts and improve them based on the instruction.", - model: improveModel, + prompt: formatCopyPrompt(built.snapshot, improveInstruction), + system: formatImproveSystemPrompt(), + model: normalizeLiteLLMModelId(improveModel), provider: improveProvider || undefined, history: [], }; const improveActionId = modelActions[improveProvider]?.id || modelAction?.id; - const data = await apiClient.queryAction(improveActionId, improvePayload); - preserveScroll(() => setImproveResult(data.response || "")); + const data = unwrapQueryActionResponse( + await apiClient.queryAction(improveActionId, improvePayload), + ); + preserveScroll(() => + setImproveResult(typeof data.response === "string" ? data.response : ""), + ); } catch (error: any) { preserveScroll(() => setImproveResult(`Error: ${error.message}`)); } finally { @@ -1046,16 +1113,25 @@ Provide improvement instruction on how to improve the prompt. Return a raw markd const handleExport = () => { if (parentInteractions.length === 0) return; - const dataToExport = { - parentInteractions: parentInteractions, - pagination: pagination, - selectedParentIndex: selectedParentIndex, - selectedMetricIndex: selectedMetricIndex, - metadata: { - exportedAt: new Date().toISOString(), - agentId: targetAgentId, - }, - }; + const dataToExport = buildExportV2({ + parentInteractions, + pagination, + selectedParentIndex, + selectedMetricIndex, + selection: selectedInteraction + ? { + user_prompt: selectedInteraction.data?.user_prompt || "", + system_prompt: selectedInteraction.data?.system_prompt || "", + historyText, + replayText, + toolsText, + replayModel, + provider: selectedProvider, + testResult, + } + : null, + agentId: targetAgentId, + }); const blob = new Blob([JSON.stringify(dataToExport, null, 2)], { type: "application/json", @@ -1078,45 +1154,39 @@ Provide improvement instruction on how to improve the prompt. Return a raw markd reader.onload = (event) => { try { const content = event.target?.result as string; - const parsed = JSON.parse(content); + const imported = parseImportFile(JSON.parse(content)); - // Check if it's the new format (full list) or legacy format (single interaction) - if ( - parsed.parentInteractions && - Array.isArray(parsed.parentInteractions) - ) { + if (imported.kind === "invalid") { + setError(imported.error); + e.target.value = ""; + return; + } + + if (imported.kind === "v2" || imported.kind === "v1") { + if (imported.kind === "v2" && imported.selection) { + skipEditorSyncRef.current = true; + pendingImportSelectionRef.current = imported.selection; + } preserveScroll(() => { - setParentInteractions(parsed.parentInteractions); - setPagination(parsed.pagination || null); - - const pIdx = - typeof parsed.selectedParentIndex === "number" - ? parsed.selectedParentIndex - : 0; - const mIdx = - typeof parsed.selectedMetricIndex === "number" - ? parsed.selectedMetricIndex - : 0; - - if (parsed.parentInteractions.length > 0) { - selectInteraction(pIdx, mIdx, parsed.parentInteractions); + setParentInteractions(imported.parentInteractions); + setPagination((imported.pagination as typeof pagination) || null); + if (imported.parentInteractions.length > 0) { + selectInteraction( + imported.selectedParentIndex, + imported.selectedMetricIndex, + imported.parentInteractions, + ); } }); } else { - // Legacy format or single interaction export - const interactionData = parsed.interaction || parsed; - const testResultData = parsed.testResult || null; - - if (interactionData?.data) { - preserveScroll(() => { + preserveScroll(() => { + if (parentInteractions.length === 0) { setSelectedParentIndex(null); setSelectedMetricIndex(null); - setSelectedInteraction(interactionData); - setTestResult(testResultData); - }); - } else { - setError("Invalid import file format"); - } + } + setSelectedInteraction(imported.interaction); + setTestResult(imported.testResult); + }); } } catch (err) { console.error("Import failed", err); @@ -1186,7 +1256,7 @@ Provide improvement instruction on how to improve the prompt. Return a raw markd +
+ + +
LM only — tools proposed, not executed - {retestTools.source === "stub" && - ((selectedInteraction.data.tool_calls || []).length > - 0 || - selectedInteraction.data.finish_reason === - "tool_calls") && ( + {retestTools.source === "stub" && ( )} - {/* Test Result — show tool_calls when present; else response text. */} + {/* Test Result — show response and tool_calls when present. */} {testResult && (
{ const toolCalls = testResult.data?.tool_calls ?? []; - if (Array.isArray(toolCalls) && toolCalls.length > 0) { - return ( - - ); - } const tr = testResult.data?.response ?? testResult.response ?? ""; - const tp = tryParseJsonDisplay(tr); - if (tp != null) { + const hasTools = + Array.isArray(toolCalls) && toolCalls.length > 0; + const tp = + typeof tr === "string" && tr + ? tryParseJsonDisplay(tr) + : null; + if (!hasTools && !tr) { return ( - +
+                                (empty response, no tool_calls)
+                              
); } return ( -
-                              {tr || "(empty response, no tool_calls)"}
-                            
+ <> + {hasTools && ( +
+

+ tool_calls +

+ +
+ )} + {!!tr && + (tp != null ? ( +
+

+ response +

+ +
+ ) : ( +
+                                    {tr}
+                                  
+ ))} + ); })() ) : ( @@ -1972,30 +2096,10 @@ Provide improvement instruction on how to improve the prompt. Return a raw markd