From 5bba6b11fa6920e60f437da818d1ab7f7267d320 Mon Sep 17 00:00:00 2001 From: fall-development-rob Date: Sun, 10 May 2026 12:13:49 +0100 Subject: [PATCH] =?UTF-8?q?feat(phase-32-wave-2):=20adapter-side=20consoli?= =?UTF-8?q?dation=20=E2=80=94=20MCP=20helpers=20+=20FileJsonStore=20+=20sh?= =?UTF-8?q?ared=20specialist=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 32 Wave 2 of the architecture refactor planned in docs/plans/phase-32-architecture.md. Three independent consolidations shipped via parallel ruflo swarm; all touch packages/harness/ only. A. MCP transport helpers centralized • New `toCanonicalTool(wireTool, prefix)` and `unwrapMcpContent(result)` in packages/harness/src/mcp-client/name-resolver.ts. • `stdio.ts`, `sse.ts`, `http.ts` now import the shared helpers. • Standardized on `.find()` semantics for content extraction (closes the previous `[0]` vs `.find()` divergence — stdio + sse both used `[0]`, only http used `.find()`; `.find()` is the more defensive choice when servers return non-text leading parts like resource refs). • LOC: −75 across 3 transports, +57 in name-resolver = net −18. B. FileJsonStore primitive extracted • New packages/harness/src/persistence/file-json-store.ts (93 LOC) and persistence/index.ts (8 LOC) — atomic writes via .tmp + rename, mkdir-on-init, list/load/save/delete with optional filter + compare. • src/audit/chain.ts: 77 → 27 LOC (createFileAuditSink is now a thin wrapper that builds an AuditRecord-typed FileJsonStore + composes the {agentId, since} filter). • src/memory/session.ts: 90 → 38 LOC (createFileSessionStore similarly thin; updates updated_at then delegates to the store; lists with {agentId, status} filter). • Public surface adds `createFileJsonStore`, `FileJsonStore`, `FileJsonStoreOptions` exports for any downstream consumer that wants a typed file store of their own. C. Shared specialist prompt fragments • New packages/harness/src/agents/specialists/shared-prompt.ts (78 LOC) exports four template constants: SPECIALIST_OPERATING_MODE — delegated-mode preamble TOOL_CALLING_CONVENTION — bare-name + { input: {...} } QUALITY_GATE_NO_LLM_ARITHMETIC — every number from a tool call TRACEABILITY_TABLE_FOOTER — | # | Tool | Inputs | Output | • All 8 specialist files (equity, credit, fixed-income, derivatives, quant-risk, macro, private-markets, esg-regulatory) refactored to interpolate the shared constants instead of carrying verbatim or paraphrased copies. Domain-specific content (role, tool inventory, calling examples) stays per-file. • A few minor prose alignments where specialists had paraphrased governance prose (credit, quant-risk) — converted to canonical wording so a single edit propagates. Tests: • 159 passed | 8 skipped (was 110 at Wave 1 baseline) — net +49 tests: file-json-store.test.ts (10), mcp-client.test.ts (+10 for the new helpers), shared-prompt.test.ts (29 — `it.each` over 8 specialists × 3 governance assertions + 5 constant-level checks). • Existing tests (audit-chain, session-memory, mcp-client-sse, mcp-client-http, providers-*, agents, core) unchanged and passing — public APIs of all stores and transports preserved. Net repo impact: 16 files modified + 4 new = 20 files, +328 / −369 LOC inside packages/harness/ (~−40 net). The win is structural rather than LOC-headline: future governance/transport edits become 1-file changes. Phase 32 Wave 2 success metrics: • toCanonicalTool / unwrapMcpContent declarations: 3 each → 1 each ✓ • Audit + Session file-store implementations: 2 → 1 (FileJsonStore) ✓ • .find() vs [0] content-unwrap divergence: closed ✓ • Specialist governance prose duplication (8 × ~25 LOC): centralized ✓ Future: Phase 33 will replace prompt-fragment interpolation with a true skill-driven specialist model (parse .claude/skills/*/SKILL.md frontmatter, inject as system context, reduce specialist AgentDefs to ~10 lines each). Wave 2C's shared-prompt.ts is the stepping stone — the canonical text already lives in one place, ready to migrate verbatim into a skill file. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../harness/src/agents/specialists/credit.ts | 25 ++-- .../src/agents/specialists/derivatives.ts | 28 ++--- .../harness/src/agents/specialists/equity.ts | 26 ++-- .../src/agents/specialists/esg-regulatory.ts | 29 ++--- .../src/agents/specialists/fixed-income.ts | 28 ++--- .../harness/src/agents/specialists/macro.ts | 31 ++--- .../src/agents/specialists/private-markets.ts | 30 ++--- .../src/agents/specialists/quant-risk.ts | 32 ++--- .../src/agents/specialists/shared-prompt.ts | 78 ++++++++++++ packages/harness/src/audit/chain.ts | 81 +++--------- packages/harness/src/index.ts | 7 ++ packages/harness/src/mcp-client/http.ts | 41 ++---- .../harness/src/mcp-client/name-resolver.ts | 70 ++++++++++- packages/harness/src/mcp-client/sse.ts | 46 +------ packages/harness/src/mcp-client/stdio.ts | 28 +---- packages/harness/src/memory/session.ts | 97 ++++---------- .../src/persistence/file-json-store.ts | 93 ++++++++++++++ packages/harness/src/persistence/index.ts | 8 ++ .../harness/tests/file-json-store.test.ts | 119 ++++++++++++++++++ packages/harness/tests/mcp-client.test.ts | 98 ++++++++++++++- packages/harness/tests/shared-prompt.test.ts | 94 ++++++++++++++ 21 files changed, 720 insertions(+), 369 deletions(-) create mode 100644 packages/harness/src/agents/specialists/shared-prompt.ts create mode 100644 packages/harness/src/persistence/file-json-store.ts create mode 100644 packages/harness/src/persistence/index.ts create mode 100644 packages/harness/tests/file-json-store.test.ts create mode 100644 packages/harness/tests/shared-prompt.test.ts diff --git a/packages/harness/src/agents/specialists/credit.ts b/packages/harness/src/agents/specialists/credit.ts index fbe132fd..29de41ae 100644 --- a/packages/harness/src/agents/specialists/credit.ts +++ b/packages/harness/src/agents/specialists/credit.ts @@ -11,20 +11,22 @@ */ import type { AgentDef } from "../../types.js"; +import { + SPECIALIST_OPERATING_MODE, + TOOL_CALLING_CONVENTION, + QUALITY_GATE_NO_LLM_ARITHMETIC, + TRACEABILITY_TABLE_FOOTER, +} from "./shared-prompt.js"; const systemPrompt = `\ You are the CFA Credit Analyst: an institutional specialist in credit-risk \ assessment, credit-derivative pricing, and credit portfolio analytics. You are \ dispatched by the chief-analyst via the \`delegate_to_credit_analyst\` tool. \ -Every number in your deliverable must come from a tool call — LLM-generated \ -arithmetic is prohibited. +${QUALITY_GATE_NO_LLM_ARITHMETIC} 1. ROLE AND OPERATING MODE - You receive a self-contained \`sub_prompt\` from the chief-analyst. The \ -parent conversation is not visible to you. Read the sub-prompt carefully: it \ -will specify the issuer, data inputs, required outputs, and acceptance criteria. \ -Treat it as a complete engagement brief. +${SPECIALIST_OPERATING_MODE} You operate with 128-bit decimal precision via the cfa-core compute tools. \ All leverage, coverage, PD, and spread figures must be produced by named tool \ @@ -33,10 +35,9 @@ traceability table appended to every deliverable. 2. MCP TOOL SURFACE - At the harness boundary use BARE tool names (e.g., \`credit_metrics\`). \ -The harness translates to wire names internally — never include the wire prefix \ -in your tool calls. All tool inputs use a wrapped envelope: - { "input": { ...params... } } +${TOOL_CALLING_CONVENTION} + + Tool inventory: 2a. cfa-core compute tools (credit domain) \`credit_metrics\` — full ratio suite: leverage, coverage, DSCR, @@ -206,7 +207,7 @@ sovereign ratings. 7. Credit Derivatives (if requested) — CDS spread, CVA, basis. 8. Scenario Analysis — base / bull / bear outcomes for the primary metric. c) Risk section: top three downside drivers with quantitative impact. - d) Traceability table: | # | Tool | Key Inputs | Output | — one row per call. + d) ${TRACEABILITY_TABLE_FOOTER} Format: institutional memo. Plain prose with numbered sections and tables. \ No markdown embellishments beyond headers and tables. Percentages to two decimal \ @@ -216,7 +217,7 @@ places; dollar amounts to the nearest thousand unless otherwise specified. Before delivering any output, verify: - Every figure in the body appears in the traceability table. - - No number was hand-calculated or estimated by the language model. + - ${QUALITY_GATE_NO_LLM_ARITHMETIC} - Synthetic rating compared to agency rating; divergence flagged if > one notch. - Z-score distress zone triggers documented with red-flag notation. - Covenant headroom < 15% triggers \`stress_test\` validation. diff --git a/packages/harness/src/agents/specialists/derivatives.ts b/packages/harness/src/agents/specialists/derivatives.ts index d4e9197f..d09c0e80 100644 --- a/packages/harness/src/agents/specialists/derivatives.ts +++ b/packages/harness/src/agents/specialists/derivatives.ts @@ -9,6 +9,12 @@ */ import type { AgentDef } from "../../types.js"; +import { + SPECIALIST_OPERATING_MODE, + TOOL_CALLING_CONVENTION, + QUALITY_GATE_NO_LLM_ARITHMETIC, + TRACEABILITY_TABLE_FOOTER, +} from "./shared-prompt.js"; const systemPrompt = `\ You are the CFA Derivatives Analyst, a specialist in derivatives pricing, \ @@ -17,17 +23,11 @@ dispatched by the CFA Chief Analyst. 1. OPERATING MODE - You receive a self-contained \`sub_prompt\` from the chief. You do not \ -see the parent conversation. Treat the sub_prompt (and any structured context \ -block below it) as the complete specification of your task. Produce a \ -structured analysis the chief can incorporate into their memo, including your \ -own traceability table. Do not ask follow-up questions; flag data gaps \ -explicitly and continue with what you have. +${SPECIALIST_OPERATING_MODE} 2. TOOL INVENTORY - You have access to the following bare-name tools. All inputs use a wrapped \ -envelope: { "input": { ...params... } }. Never include wire prefixes. + You have access to the following bare-name tools. Compute (cfa-core, 128-bit decimal precision): option_pricer — Black-Scholes / binomial pricing + Greeks @@ -68,12 +68,7 @@ envelope: { "input": { ...params... } }. Never include wire prefixes. 3. TOOL CALLING CONVENTION - Call tools by bare name. Wrap every input in the standard envelope: - { "input": { "param1": value, "param2": value, ... } } - - Execute independent calls in the same turn. For chains (data → compute), \ -retrieve data first, then pass exact values into compute tools in the next \ -turn. Never interpolate or re-derive values that came from a prior tool result. +${TOOL_CALLING_CONVENTION} 4. DOMAIN EXPERTISE @@ -146,15 +141,14 @@ metric (option value, swap MTM, CB price, etc.). e) Risk section: top three downside drivers with quantified impact \ (e.g., vega at 1-vol-point move, DV01 at 1 bp, delta at 1% spot move). - f) Tool-call traceability table (mandatory, one row per invocation): - | # | Tool | Key Inputs | Output | + f) ${TRACEABILITY_TABLE_FOOTER} 6. QUALITY GATE Before returning your analysis, verify: - Every number in sections (b) through (e) maps to a row in the \ traceability table. - - No number was hand-calculated or estimated by the language model. + - ${QUALITY_GATE_NO_LLM_ARITHMETIC} - Assumptions are stated with source or convention citation. - Scenario analysis (base / bull / bear) is present for any pricing output. - If a required data source is unavailable, flag the section INCOMPLETE \ diff --git a/packages/harness/src/agents/specialists/equity.ts b/packages/harness/src/agents/specialists/equity.ts index e8741863..c1b6502c 100644 --- a/packages/harness/src/agents/specialists/equity.ts +++ b/packages/harness/src/agents/specialists/equity.ts @@ -12,29 +12,28 @@ */ import type { AgentDef } from "../../types.js"; +import { + SPECIALIST_OPERATING_MODE, + TOOL_CALLING_CONVENTION, + QUALITY_GATE_NO_LLM_ARITHMETIC, + TRACEABILITY_TABLE_FOOTER, +} from "./shared-prompt.js"; const systemPrompt = `\ You are the CFA Equity Analyst, a specialist in fundamental equity research \ and valuation. You are dispatched by the CFA Chief Analyst to execute \ -equity-specific sub-tasks with institutional rigor. Every figure you report \ -must be produced by a tool call — LLM-generated arithmetic is prohibited. +equity-specific sub-tasks with institutional rigor. \ +${QUALITY_GATE_NO_LLM_ARITHMETIC} 1. DELEGATION OPERATING MODE - You receive a self-contained \`sub_prompt\` (and optional structured \ -\`context\`) from the chief analyst. The parent conversation is not visible \ -to you. The \`sub_prompt\` contains all data, company identifiers, prior \ -findings, and acceptance criteria you need to complete the task. +${SPECIALIST_OPERATING_MODE} When context JSON is appended below the prompt, treat those values as \ authoritative inputs — anchor calculations on them and do not re-fetch data \ the chief has already provided unless freshness is required. - Input calling convention (all tools, including compute tools): - { "input": { ...params... } } - - Use BARE tool names only (e.g., \`dcf_model\`, \`fmp_income_statement\`). \ -The harness resolves bare names to wire-prefixed MCP names internally. +${TOOL_CALLING_CONVENTION} 2. TOOL INVENTORY @@ -223,8 +222,7 @@ multiple, peer set, date of market data. (sensitivity from the matrix). f) EQ scorecard table (when EQ screening requested): | Metric | Score | \ Flag | — one row per EQ tool. - g) Tool-call traceability table (mandatory, always last): | # | Tool | \ -Key Inputs | Output | — one row per invocation. + g) ${TRACEABILITY_TABLE_FOOTER} Format: institutional memo, plain prose with structured tables. No \ decorative markdown beyond headers and tables. Percentages and multiples to \ @@ -235,7 +233,7 @@ requires greater precision. Before returning your deliverable: - Every number in the body has a corresponding row in the traceability table. - - No number is LLM-estimated or hand-calculated. + - ${QUALITY_GATE_NO_LLM_ARITHMETIC} - Terminal value falls within 50-75% of total EV; if outside range, document why. - Comps set contains 4-6 peers with documented selection rationale. - M-Score, F-Score, and Sloan ratio are computed from tool outputs, not derived manually. diff --git a/packages/harness/src/agents/specialists/esg-regulatory.ts b/packages/harness/src/agents/specialists/esg-regulatory.ts index ef4a9724..90c4b456 100644 --- a/packages/harness/src/agents/specialists/esg-regulatory.ts +++ b/packages/harness/src/agents/specialists/esg-regulatory.ts @@ -10,6 +10,12 @@ */ import type { AgentDef } from "../../types.js"; +import { + SPECIALIST_OPERATING_MODE, + TOOL_CALLING_CONVENTION, + QUALITY_GATE_NO_LLM_ARITHMETIC, + TRACEABILITY_TABLE_FOOTER, +} from "./shared-prompt.js"; const systemPrompt = `\ You are the CFA ESG/Regulatory Analyst, a specialist in environmental, social, \ @@ -19,18 +25,11 @@ as a sub-agent dispatched by the CFA Chief Analyst. 1. OPERATING MODE - You receive a self-contained \`sub_prompt\` from the chief. You do not \ -see the parent conversation or any context outside what was passed to you. \ -Treat the sub_prompt (and any structured context block below it) as the \ -complete specification of your task. Produce a structured analysis the chief \ -can incorporate verbatim into their memo, including your own tool-call \ -traceability table. Do not ask follow-up questions; work with what you have \ -and flag any data gaps explicitly. +${SPECIALIST_OPERATING_MODE} 2. TOOL INVENTORY - You have access to the following bare-name tools. All inputs use a wrapped \ -envelope: { "input": { ...params... } }. Never include wire prefixes. + You have access to the following bare-name tools. ESG and climate (cfa-core, 128-bit decimal precision): esg_score — Sector-weighted ESG scoring, 7-level rating (AAA-CCC) @@ -129,12 +128,7 @@ envelope: { "input": { ...params... } }. Never include wire prefixes. 3. TOOL CALLING CONVENTION - Call tools by bare name. Wrap every input in the standard envelope: - { "input": { "param1": value, "param2": value, ... } } - - Execute independent calls in the same turn. For chains (data → compute), \ -retrieve data first, then pass exact values into compute tools in the next \ -turn. Never interpolate or re-derive values that came from a prior tool result. +${TOOL_CALLING_CONVENTION} 4. DOMAIN EXPERTISE @@ -215,15 +209,14 @@ requirement showing threshold, computed value, and pass/fail status. e) Risk section: top three regulatory, reputational, or tax downside \ drivers with quantified impact. - f) Tool-call traceability table (mandatory, one row per invocation): - | # | Tool | Key Inputs | Output | + f) ${TRACEABILITY_TABLE_FOOTER} 6. QUALITY GATE Before returning your analysis, verify: - Every number in sections (b) through (e) maps to a row in the \ traceability table. - - No number was hand-calculated or estimated by the language model. + - ${QUALITY_GATE_NO_LLM_ARITHMETIC} - Regulatory thresholds are cited with their source rule or standard. - Conservative interpretation has been applied wherever rules are ambiguous. - Scenario analysis (base / stressed) is present for any regulatory capital \ diff --git a/packages/harness/src/agents/specialists/fixed-income.ts b/packages/harness/src/agents/specialists/fixed-income.ts index 9ffe5232..ba2be41e 100644 --- a/packages/harness/src/agents/specialists/fixed-income.ts +++ b/packages/harness/src/agents/specialists/fixed-income.ts @@ -6,6 +6,12 @@ */ import type { AgentDef } from "../../types.js"; +import { + SPECIALIST_OPERATING_MODE, + TOOL_CALLING_CONVENTION, + QUALITY_GATE_NO_LLM_ARITHMETIC, + TRACEABILITY_TABLE_FOOTER, +} from "./shared-prompt.js"; const systemPrompt = `\ You are the CFA Fixed Income Analyst: an institutional-grade specialist in \ @@ -13,22 +19,17 @@ fixed income securities and interest rate markets. You are invoked by the CFA \ Chief Analyst to handle bond pricing, yield curve construction, duration / \ convexity analytics, credit spreads, structured products, inflation-linked \ instruments, repo financing, municipal bonds, sovereign and emerging-market \ -debt, and short-rate modelling. Every number in your output must trace to an \ -explicit tool invocation — LLM-generated arithmetic is prohibited. +debt, and short-rate modelling. ${QUALITY_GATE_NO_LLM_ARITHMETIC} 1. ROLE AND OPERATING MODE - You operate as a depth specialist receiving a self-contained sub-prompt \ -from the chief. You do not see the parent conversation. Execute all required \ -data-retrieval and compute calls, then return a structured analysis that the \ -chief can incorporate into its consolidated deliverable. Your tool-call \ -traceability table is mandatory. +${SPECIALIST_OPERATING_MODE} 2. MCP TOOL SURFACE - At the harness boundary, use BARE tool names (e.g., \`bond_pricer\`). The \ -harness translates to wire names internally — never include the wire prefix. \ -All tool inputs use the wrapped envelope: { "input": { ...params... } }. +${TOOL_CALLING_CONVENTION} + + Tool inventory: 2a. cfa-core compute tools (128-bit decimal precision) @@ -168,10 +169,7 @@ widening) using \`scenario_analysis\`, \`sensitivity_matrix\`, or \ e) Close with a risk section addressing the top three downside drivers \ and their quantitative impact (DV01 for rate risk; spread DV01 for credit \ risk; negative convexity for MBS). - f) Append a tool-call traceability table: - | # | Tool | Key Inputs | Output | - — one row per tool invocation. Every number in the body must have a \ - corresponding row in this table. + f) ${TRACEABILITY_TABLE_FOOTER} Format: institutional memo, plain prose with structured tables. No \ markdown embellishments beyond headers and tables. Numerical precision: \ @@ -183,7 +181,7 @@ unless context requires finer precision. Before returning the analysis: - Verify every number in the body has a row in the traceability table. - - No number was hand-calculated or estimated by the language model. + - ${QUALITY_GATE_NO_LLM_ARITHMETIC} - Assumptions (settlement date, day count, prepayment speed, discount \ curve) are stated with justification. - If a required data source is unavailable (API key missing, vendor not \ diff --git a/packages/harness/src/agents/specialists/macro.ts b/packages/harness/src/agents/specialists/macro.ts index b4f75b7f..63e742a9 100644 --- a/packages/harness/src/agents/specialists/macro.ts +++ b/packages/harness/src/agents/specialists/macro.ts @@ -7,25 +7,23 @@ */ import type { AgentDef } from "../../types.js"; +import { + SPECIALIST_OPERATING_MODE, + TOOL_CALLING_CONVENTION, + QUALITY_GATE_NO_LLM_ARITHMETIC, + TRACEABILITY_TABLE_FOOTER, +} from "./shared-prompt.js"; const systemPrompt = `\ You are the CFA Macro Analyst: an institutional specialist in macroeconomic \ strategy, sovereign risk, FX markets, commodities, and emerging markets. \ You are dispatched by the CFA Chief Analyst to handle macro sub-tasks that \ require a focused tool subset and dedicated domain expertise. You do not \ -delegate to other agents. - -Every number in your output must trace to a specific tool call with logged \ -inputs. LLM-generated arithmetic is prohibited. If a required calculation \ -cannot be sourced from a tool invocation, state that gap explicitly and \ -identify the data needed to close it. +delegate to other agents. ${QUALITY_GATE_NO_LLM_ARITHMETIC} 1. ROLE AND OPERATING MODE - You receive a self-contained \`sub_prompt\` from the chief-analyst. Treat it \ -as your complete brief — you do not have access to the parent conversation. \ -The \`context\` object may carry identifiers, prior numeric findings, or \ -acceptance criteria; use all of it. +${SPECIALIST_OPERATING_MODE} Your deliverable returns to the chief-analyst, who aggregates it into the \ final memo. Write in institutional voice. Preserve full tool-call traceability \ @@ -109,12 +107,7 @@ governance indicators, and trade statistics. 3. TOOL CALLING CONVENTION - All tool inputs use the wrapped envelope form: - { "input": { ...params... } } - - Use bare tool names (e.g., \`country_risk_premium\`). The harness resolves \ -wire-prefixed names internally — never include the MCP wire prefix in tool \ -calls. +${TOOL_CALLING_CONVENTION} Prefer free data tools (FRED, World Bank, ACLED, GDELT) before paid-vendor \ tools (Moody's, LSEG, S&P) unless the task explicitly requires vendor \ @@ -163,9 +156,7 @@ with the key variable changed and the resulting impact. Geopolitical risk must appear in this section whenever ACLED/GDELT/GDACS data \ show elevated signals. - TOOL-CALL TRACEABILITY TABLE: - | # | Tool | Key Inputs | Output | - One row per tool invocation, in order of execution. + ${TRACEABILITY_TABLE_FOOTER} Numerical precision: percentages and basis points to two decimal places; \ currency rates to four decimal places; index levels to two decimal places. @@ -174,7 +165,7 @@ currency rates to four decimal places; index levels to two decimal places. Before returning your memo, verify: - Every number in the analysis body appears in the traceability table. - - No number was hand-calculated or LLM-estimated. + - ${QUALITY_GATE_NO_LLM_ARITHMETIC} - Scenarios are present for every macro conclusion (this domain has high \ regime-change risk; scenarios are mandatory, not optional). - If a required data source is unavailable, the gap is documented with the \ diff --git a/packages/harness/src/agents/specialists/private-markets.ts b/packages/harness/src/agents/specialists/private-markets.ts index 0a1bbe98..cf3af2fb 100644 --- a/packages/harness/src/agents/specialists/private-markets.ts +++ b/packages/harness/src/agents/specialists/private-markets.ts @@ -11,6 +11,12 @@ */ import type { AgentDef } from "../../types.js"; +import { + SPECIALIST_OPERATING_MODE, + TOOL_CALLING_CONVENTION, + QUALITY_GATE_NO_LLM_ARITHMETIC, + TRACEABILITY_TABLE_FOOTER, +} from "./shared-prompt.js"; const systemPrompt = `\ You are the CFA Private Markets Analyst, a specialist in private equity, \ @@ -19,18 +25,11 @@ by the CFA Chief Analyst. 1. OPERATING MODE - You receive a self-contained \`sub_prompt\` from the chief. You do not see \ -the parent conversation or any context outside what was passed to you. Treat \ -the sub_prompt (and any structured context block below it) as the complete \ -specification of your task. Produce a structured analysis the chief can \ -incorporate verbatim into their memo, including your own tool-call traceability \ -table. Do not ask follow-up questions; work with what you have and flag any \ -data gaps explicitly. +${SPECIALIST_OPERATING_MODE} 2. TOOL INVENTORY - You have access to the following bare-name tools. All inputs use a wrapped \ -envelope: { "input": { ...params... } }. Never include wire prefixes. + You have access to the following bare-name tools. Compute — LBO / PE (cfa-core, 128-bit decimal precision): lbo_model — Full LBO with multi-tranche debt and cash sweep @@ -114,13 +113,7 @@ envelope: { "input": { ...params... } }. Never include wire prefixes. 3. TOOL CALLING CONVENTION - Call tools by bare name. Wrap every input in the standard envelope: - { "input": { "param1": value, "param2": value, ... } } - - Execute independent calls in the same turn. For chains (data → compute), \ -retrieve financials first, then pass exact extracted values into compute tools \ -in the next turn. Never interpolate or re-derive values that came from a \ -prior tool result. +${TOOL_CALLING_CONVENTION} 4. DOMAIN EXPERTISE @@ -228,15 +221,14 @@ metric (IRR, MOIC, EPS accretion, or net TVPI). (e.g., 100 bps increase in financing cost reduces IRR by X%; 1.0x exit \ multiple compression reduces MOIC by Y%). - f) Tool-call traceability table (mandatory, one row per invocation): - | # | Tool | Key Inputs | Output | + f) ${TRACEABILITY_TABLE_FOOTER} 6. QUALITY GATE Before returning your analysis, verify: - Every number in sections (b) through (e) maps to a row in the \ traceability table. - - No number was hand-calculated or estimated by the language model. + - ${QUALITY_GATE_NO_LLM_ARITHMETIC} - Assumptions are stated with source or convention citation. - Scenario analysis (base / bull / bear) is present for any return output. - Sources & uses table balances (total sources = total uses) for any LBO \ diff --git a/packages/harness/src/agents/specialists/quant-risk.ts b/packages/harness/src/agents/specialists/quant-risk.ts index d440bf13..43322dd2 100644 --- a/packages/harness/src/agents/specialists/quant-risk.ts +++ b/packages/harness/src/agents/specialists/quant-risk.ts @@ -9,6 +9,12 @@ */ import type { AgentDef } from "../../types.js"; +import { + SPECIALIST_OPERATING_MODE, + TOOL_CALLING_CONVENTION, + QUALITY_GATE_NO_LLM_ARITHMETIC, + TRACEABILITY_TABLE_FOOTER, +} from "./shared-prompt.js"; const systemPrompt = `\ You are the CFA Quant Risk Analyst: an institutional specialist in quantitative \ @@ -17,25 +23,22 @@ allocation, and index construction. You are dispatched by the CFA Chief Analyst via the delegation mechanism — you cannot see the parent conversation and must \ operate solely on the sub-prompt and structured context you receive. -Every number you produce must originate from an MCP tool call. LLM-generated \ -arithmetic is prohibited. If a required calculation has no corresponding tool, \ -state that explicitly and document what data would be required. +${QUALITY_GATE_NO_LLM_ARITHMETIC} 1. ROLE AND OPERATING MODE - You operate in specialist mode: self-contained, single-task focus. The \ -chief-analyst has handed you a specific quantitative or risk sub-task. Execute \ -it fully using the tool subset below, then return a structured analysis with \ -a complete tool-call traceability table. Do not attempt to answer questions \ -outside your domain — escalate gaps back via your output text. +${SPECIALIST_OPERATING_MODE} + + You operate in specialist mode: self-contained, single-task focus. Execute \ +the task fully using the tool subset below, then return a structured analysis \ +with a complete tool-call traceability table. Do not attempt to answer \ +questions outside your domain — escalate gaps back via your output text. 2. MCP TOOL SURFACE - All tool calls use bare names (e.g., \`factor_model\`). The harness \ -translates bare names to wire names internally — never include the wire prefix. +${TOOL_CALLING_CONVENTION} - All tool inputs use a wrapped envelope: - { "input": { ...params... } } + Tool inventory: 2a. cfa-core compute — quantitative and risk tools (128-bit decimal precision) @@ -189,8 +192,7 @@ scenario_analysis or sensitivity_matrix. e) Close with a risk section identifying the top three quantitative risks \ (e.g., factor crowding, tail-risk concentration, model misspecification) and \ their numerical impact. - f) Append a tool-call traceability table: | # | Tool | Key Inputs | Output | - — one row per tool invocation. + f) ${TRACEABILITY_TABLE_FOOTER} Format: institutional memo, plain prose with structured tables. No markdown \ embellishments beyond headers and tables. Numerical precision: two decimal \ @@ -214,7 +216,7 @@ dollar figures rounded to the nearest thousand unless context requires more. Before returning your analysis: - Every number in the body has a row in the traceability table. - - No number was hand-calculated or estimated by the language model. + - ${QUALITY_GATE_NO_LLM_ARITHMETIC} - All assumptions are stated with explicit justification. - If a required tool or data source is unavailable (e.g., FactSet not \ subscribed), state the gap and what the fallback assumption would be. diff --git a/packages/harness/src/agents/specialists/shared-prompt.ts b/packages/harness/src/agents/specialists/shared-prompt.ts new file mode 100644 index 00000000..d66e5d3c --- /dev/null +++ b/packages/harness/src/agents/specialists/shared-prompt.ts @@ -0,0 +1,78 @@ +/** + * Shared system-prompt fragments interpolated by every CFA specialist + * agent. Centralised so governance edits (LLM-arithmetic-prohibited, the + * traceability mandate, the tool-calling envelope) are 1-file changes + * instead of 8-file copy-paste. + */ + +/** + * Operating mode preamble — every specialist agent's first operating-mode + * section starts with this paragraph (or incorporates it). Standard across + * all 8 specialists. + */ +export const SPECIALIST_OPERATING_MODE = `\ +You receive a self-contained \`sub_prompt\` from the chief. You do not see \ +the parent conversation or any context outside what was passed to you. Treat \ +the sub_prompt (and any structured context block below it) as the complete \ +specification of your task. Produce a structured analysis the chief can \ +incorporate into their memo, including your own tool-call traceability table. \ +Do not ask follow-up questions; work with what you have and flag any data \ +gaps explicitly.`; + +/** + * Tool-calling convention block — specialists MUST use the bare tool name + * and the wrapped { "input": { ... } } envelope. Identical across all 8 + * specialists; centralised to prevent drift. + */ +export const TOOL_CALLING_CONVENTION = `\ +Call tools by bare name (e.g., \`option_pricer\`, NOT \ +\`mcp__plugin_cfa-core_cfa-core__option_pricer\`). The harness translates \ +bare names to the wire-prefixed form internally — never include the wire \ +prefix in tool calls. Wrap every input in the standard envelope: + + { "input": { ...params... } } + +Execute independent calls in the same turn. For chains (data → compute), \ +retrieve data first, then pass exact values into compute tools in the next \ +turn. Never interpolate or re-derive values that came from a prior tool result.`; + +/** + * Quality-gate prose — the "no LLM arithmetic" rule. Specialists embed + * this sentence in their quality-gate sections. + */ +export const QUALITY_GATE_NO_LLM_ARITHMETIC = `\ +LLM-generated arithmetic is prohibited. Every numerical result must be \ +sourced from a tool invocation with logged inputs. If a number cannot be \ +sourced from an available tool, state the gap explicitly and identify the \ +data needed to close it — do not estimate.`; + +/** + * Traceability table footer — every specialist closes with this mandate. + * The chief aggregates specialists' outputs; the table is the audit trail. + */ +export const TRACEABILITY_TABLE_FOOTER = `\ +Close every analysis with a tool-call traceability table (mandatory, one \ +row per invocation): + + | # | Tool | Key Inputs | Output | + +Every number in the body must have a corresponding row in this table. The \ +chief uses these tables to assemble the final memo's audit appendix.`; + +/** + * Convenience: the four fragments joined with section markers, for + * specialists that prefer to interpolate one block instead of four. + */ +export const SPECIALIST_PREAMBLE = [ + "## Operating mode (delegated)", + "", + SPECIALIST_OPERATING_MODE, + "", + "## Tool-calling convention", + "", + TOOL_CALLING_CONVENTION, + "", + "## Quality gate", + "", + QUALITY_GATE_NO_LLM_ARITHMETIC, +].join("\n"); diff --git a/packages/harness/src/audit/chain.ts b/packages/harness/src/audit/chain.ts index 490391b0..b9b366e9 100644 --- a/packages/harness/src/audit/chain.ts +++ b/packages/harness/src/audit/chain.ts @@ -1,76 +1,27 @@ /** - * File-based audit sink implementation. + * File-based audit sink — Phase 32 Wave 2. * - * Each AuditRecord is written as pretty-printed JSON to /.json. - * Writes are atomic: temp file written then renamed. + * Thin wrapper over FileJsonStore. */ -import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { AuditRecord, AuditSink } from "../types.js"; - -function tmpPath(dir: string, auditId: string): string { - return join(dir, `.${auditId}.json.tmp`); -} - -function finalPath(dir: string, auditId: string): string { - return join(dir, `${auditId}.json`); -} - -async function ensureDir(dir: string): Promise { - await mkdir(dir, { recursive: true }); -} - -async function parseRecord(dir: string, filename: string): Promise { - try { - const raw = await readFile(join(dir, filename), "utf-8"); - return JSON.parse(raw) as AuditRecord; - } catch { - return null; - } -} +import { createFileJsonStore } from "../persistence/index.js"; export function createFileAuditSink(opts: { dir: string }): AuditSink { - const { dir } = opts; + const store = createFileJsonStore({ + dir: opts.dir, + idOf: (r) => r.audit_id, + compare: (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + }); return { - async write(record: AuditRecord): Promise { - await ensureDir(dir); - const tmp = tmpPath(dir, record.audit_id); - const dest = finalPath(dir, record.audit_id); - await writeFile(tmp, JSON.stringify(record, null, 2), "utf-8"); - await rename(tmp, dest); - }, - - async read(auditId: string): Promise { - const record = await parseRecord(dir, `${auditId}.json`); - return record; - }, - - async list(filter?: { agentId?: string; since?: Date }): Promise { - await ensureDir(dir); - let entries: string[]; - try { - entries = await readdir(dir); - } catch { - return []; - } - - const jsonFiles = entries.filter((f) => f.endsWith(".json") && !f.startsWith(".")); - const records = await Promise.all(jsonFiles.map((f) => parseRecord(dir, f))); - let valid = records.filter((r): r is AuditRecord => r !== null); - - if (filter?.agentId !== undefined) { - const id = filter.agentId; - valid = valid.filter((r) => r.agent_id === id); - } - if (filter?.since !== undefined) { - const since = filter.since.getTime(); - valid = valid.filter((r) => new Date(r.timestamp).getTime() >= since); - } - - return valid.sort( - (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), - ); + write: (record) => store.save(record), + read: (auditId) => store.load(auditId), + async list(filter?: { agentId?: string; since?: Date }) { + return store.list((r) => { + if (filter?.agentId !== undefined && r.agent_id !== filter.agentId) return false; + if (filter?.since !== undefined && new Date(r.timestamp).getTime() < filter.since.getTime()) return false; + return true; + }); }, }; } diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index f3cc62ee..3083daa9 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -58,6 +58,13 @@ export { createDelegationTools, } from "./agents/delegate.js"; +// Phase 32 Wave 2 — generic file-backed JSON store primitive +export { + createFileJsonStore, + type FileJsonStore, + type FileJsonStoreOptions, +} from "./persistence/index.js"; + // Phase 32 Wave 1 — ToolCatalogValidator ACL export { filterToolsForAgent, diff --git a/packages/harness/src/mcp-client/http.ts b/packages/harness/src/mcp-client/http.ts index badd0364..11190f14 100644 --- a/packages/harness/src/mcp-client/http.ts +++ b/packages/harness/src/mcp-client/http.ts @@ -12,6 +12,8 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import type { MCPClient, CanonicalTool } from "../types.js"; import { extractBareFromWire, + toCanonicalTool, + unwrapMcpContent, type ResolvedTool, } from "./name-resolver.js"; @@ -30,33 +32,11 @@ export interface HTTPMCPServerConfig { headers?: Record; } -interface SDKToolDef { - name: string; - description?: string; - inputSchema: { - type: "object"; - properties?: Record; - required?: string[]; - }; -} - interface ServerEntry { config: HTTPMCPServerConfig; client: Client; } -function sdkToolToCanonical(tool: SDKToolDef, prefix: string): CanonicalTool { - const bareName = extractBareFromWire(tool.name, prefix); - return { - name: bareName, - description: tool.description ?? "", - input_schema: { - type: "object", - properties: (tool.inputSchema.properties ?? {}) as Record, - required: tool.inputSchema.required, - }, - }; -} /** * Builds a bare→ResolvedTool map from HTTP server configs and their reported @@ -121,12 +101,12 @@ export function createHTTPMCPClient(servers: HTTPMCPServerConfig[]): MCPClient { } const response = await client.listTools(); - const wireNames = (response.tools as SDKToolDef[]).map((t) => t.name); + const wireNames = response.tools.map((t) => t.name); wireToolsByServer.set(config.name, wireNames); clientByServer.set(config.name, client); - for (const tool of response.tools as SDKToolDef[]) { - toolCatalog.push(sdkToolToCanonical(tool, config.prefix)); + for (const tool of response.tools) { + toolCatalog.push(toCanonicalTool(tool, config.prefix)); } } @@ -159,15 +139,8 @@ export function createHTTPMCPClient(servers: HTTPMCPServerConfig[]): MCPClient { ); // Unwrap text content from the canonical MCP response shape. - const content = result.content as Array<{ type: string; text?: string }>; - const firstText = content?.find((c) => c.type === "text" && typeof c.text === "string"); - if (firstText?.text != null) { - try { - return JSON.parse(firstText.text) as unknown; - } catch { - return firstText.text; - } - } + const unwrapped = unwrapMcpContent(result); + if (unwrapped !== undefined) return unwrapped; return result; }, diff --git a/packages/harness/src/mcp-client/name-resolver.ts b/packages/harness/src/mcp-client/name-resolver.ts index fe5e8cee..3faf9287 100644 --- a/packages/harness/src/mcp-client/name-resolver.ts +++ b/packages/harness/src/mcp-client/name-resolver.ts @@ -3,8 +3,12 @@ * * Bare name: "option_pricer" * Wire name: "mcp__plugin_cfa-core_cfa-core__option_pricer" + * + * Also exports shared MCP transport helpers: + * - toCanonicalTool — wire-format → CanonicalTool + * - unwrapMcpContent — tools/call result → parsed/raw text */ -import type { MCPServerConfig } from "../types.js"; +import type { MCPServerConfig, CanonicalTool } from "../types.js"; export interface ResolvedTool { serverName: string; @@ -60,3 +64,67 @@ export function extractBareFromWire(wireName: string, prefix: string): string { } return wireName; } + +// --------------------------------------------------------------------------- +// Shared MCP transport helpers +// --------------------------------------------------------------------------- + +/** + * Wire-format tool descriptor returned by every MCP server's tools/list. + * Both the SDK Client (@modelcontextprotocol/sdk) and the stdio JSON-RPC + * transport return tools in this shape. + */ +export interface WireTool { + name: string; + description?: string; + inputSchema?: { + type?: string; + properties?: Record; + required?: string[]; + }; +} + +/** + * Translate a single wire-format tool into the harness's CanonicalTool + * shape, stripping the server's prefix from the bare name. + * + * Used by every MCP client transport (stdio, SSE, StreamableHTTP) so the + * agent loop sees a uniform CanonicalTool[] regardless of how the catalog + * was fetched. + */ +export function toCanonicalTool(tool: WireTool, prefix: string): CanonicalTool { + const bareName = extractBareFromWire(tool.name, prefix); + const schema: CanonicalTool["input_schema"] = { + type: "object", + properties: tool.inputSchema?.properties ?? {}, + }; + if (tool.inputSchema?.required !== undefined) { + schema.required = tool.inputSchema.required; + } + return { + name: bareName, + description: tool.description ?? "", + input_schema: schema, + }; +} + +/** + * Wire-format tool result returned by every MCP server's tools/call. + * Contains an array of content parts. We extract the first text part + * and try to JSON-parse its body; otherwise we return the raw string. + * + * Returns the parsed/raw text content, or undefined when no text part + * exists (server returned only resource references, images, etc.). + */ +export function unwrapMcpContent(result: unknown): unknown { + const r = result as { content?: Array<{ type: string; text?: string }> } | null | undefined; + const content = r?.content; + if (!Array.isArray(content)) return undefined; + const textPart = content.find((c) => c.type === "text" && typeof c.text === "string"); + if (textPart?.text == null) return undefined; + try { + return JSON.parse(textPart.text) as unknown; + } catch { + return textPart.text; + } +} diff --git a/packages/harness/src/mcp-client/sse.ts b/packages/harness/src/mcp-client/sse.ts index 394477f3..80d9d3e8 100644 --- a/packages/harness/src/mcp-client/sse.ts +++ b/packages/harness/src/mcp-client/sse.ts @@ -22,6 +22,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import type { MCPClient, CanonicalTool } from "../types.js"; +import { toCanonicalTool, unwrapMcpContent } from "./name-resolver.js"; // --------------------------------------------------------------------------- // Public types @@ -40,16 +41,6 @@ export interface SSEMCPServerConfig { // Internal types // --------------------------------------------------------------------------- -interface SdkTool { - name: string; - description?: string; - inputSchema?: { - type?: string; - properties?: Record; - required?: string[]; - }; -} - interface ServerEntry { config: SSEMCPServerConfig; client: Client; @@ -61,25 +52,6 @@ interface ServerEntry { const TOOL_CALL_TIMEOUT_MS = 30_000; -function extractBare(wireName: string, prefix: string): string { - if (prefix && wireName.startsWith(prefix)) { - return wireName.slice(prefix.length); - } - return wireName; -} - -function sdkToolToCanonical(tool: SdkTool, prefix: string): CanonicalTool { - return { - name: extractBare(tool.name, prefix), - description: tool.description ?? "", - input_schema: { - type: "object", - properties: tool.inputSchema?.properties ?? {}, - required: tool.inputSchema?.required, - }, - }; -} - // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- @@ -123,10 +95,10 @@ export function createSSEMCPClient(servers: SSEMCPServerConfig[]): MCPClient { entries.set(config.name, { config, client: sdkClient }); const listResult = await sdkClient.listTools(); - const sdkTools = (listResult.tools ?? []) as SdkTool[]; + const sdkTools = listResult.tools ?? []; for (const sdkTool of sdkTools) { - const canonical = sdkToolToCanonical(sdkTool, config.prefix); + const canonical = toCanonicalTool(sdkTool, config.prefix); if (bareToServer.has(canonical.name)) { const existing = bareToServer.get(canonical.name)!; throw new Error( @@ -182,16 +154,8 @@ export function createSSEMCPClient(servers: SSEMCPServerConfig[]): MCPClient { const result = await Promise.race([resultPromise, timeoutPromise]); // MCP spec: result.content is an array of content blocks. - const content = (result as { content?: Array<{ type: string; text?: string }> }) - .content; - const first = content?.[0]; - if (first?.type === "text" && typeof first.text === "string") { - try { - return JSON.parse(first.text) as unknown; - } catch { - return first.text; - } - } + const unwrapped = unwrapMcpContent(result); + if (unwrapped !== undefined) return unwrapped; return result; }, diff --git a/packages/harness/src/mcp-client/stdio.ts b/packages/harness/src/mcp-client/stdio.ts index 08fbeebd..eaa27c23 100644 --- a/packages/harness/src/mcp-client/stdio.ts +++ b/packages/harness/src/mcp-client/stdio.ts @@ -10,7 +10,8 @@ import type { MCPClient, MCPServerConfig, CanonicalTool } from "../types.js"; import { buildNameMap, resolveBareToWire, - extractBareFromWire, + toCanonicalTool, + unwrapMcpContent, type ResolvedTool, } from "./name-resolver.js"; @@ -145,18 +146,6 @@ async function listServerTools(state: ServerState): Promise { return result?.tools ?? []; } -function wireToolToCanonical(tool: WireToolDef, prefix: string): CanonicalTool { - const bareName = extractBareFromWire(tool.name, prefix); - return { - name: bareName, - description: tool.description ?? "", - input_schema: { - type: "object", - properties: tool.inputSchema?.properties ?? {}, - required: tool.inputSchema?.required, - }, - }; -} /** * Creates and returns an MCPClient that connects to all specified MCP servers @@ -183,7 +172,7 @@ export function createStdioMCPClient(servers: MCPServerConfig[]): MCPClient { // Build canonical tools for this server for (const wt of wireTools) { - toolCatalog.push(wireToolToCanonical(wt, config.prefix)); + toolCatalog.push(toCanonicalTool(wt, config.prefix)); } } @@ -218,15 +207,8 @@ export function createStdioMCPClient(servers: MCPServerConfig[]): MCPClient { } // Plugin returns: { content: [{ type: "text", text: "" }] } - const result = resp.result as { content?: Array<{ type: string; text: string }> }; - const firstContent = result?.content?.[0]; - if (firstContent?.type === "text" && typeof firstContent.text === "string") { - try { - return JSON.parse(firstContent.text) as unknown; - } catch { - return firstContent.text; - } - } + const unwrapped = unwrapMcpContent(resp.result); + if (unwrapped !== undefined) return unwrapped; return resp.result; }, diff --git a/packages/harness/src/memory/session.ts b/packages/harness/src/memory/session.ts index 603b9659..eb3a8e15 100644 --- a/packages/harness/src/memory/session.ts +++ b/packages/harness/src/memory/session.ts @@ -1,89 +1,38 @@ /** - * File-based SessionStore implementation — Phase 31 Wave 4. + * File-based SessionStore — Phase 32 Wave 2. * - * Each session is written as pretty-printed JSON at `/.json`. - * Writes are atomic: data goes to a `.tmp` file then renamed into place. + * Thin wrapper over FileJsonStore. */ -import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { SessionState, SessionStore } from "../types.js"; +import { createFileJsonStore } from "../persistence/index.js"; export interface FileSessionStoreOptions { dir: string; } -function tmpPath(filePath: string): string { - return `${filePath}.tmp`; -} - -async function writeAtomic(filePath: string, data: string): Promise { - const tmp = tmpPath(filePath); - await writeFile(tmp, data, "utf8"); - await rename(tmp, filePath); -} - -function sessionPath(dir: string, sessionId: string): string { - return join(dir, `${sessionId}.json`); -} - function nowIso(): string { return new Date().toISOString(); } export function createFileSessionStore(opts: FileSessionStoreOptions): SessionStore { - const { dir } = opts; - const ready = mkdir(dir, { recursive: true }); - - async function save(state: SessionState): Promise { - await ready; - const updated: SessionState = { ...state, updated_at: nowIso() }; - await writeAtomic(sessionPath(dir, state.session_id), JSON.stringify(updated, null, 2)); - } - - async function load(sessionId: string): Promise { - await ready; - try { - const raw = await readFile(sessionPath(dir, sessionId), "utf8"); - return JSON.parse(raw) as SessionState; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; - throw err; - } - } - - async function list( - filter?: { agentId?: string; status?: SessionState["status"] }, - ): Promise { - await ready; - const entries = await readdir(dir); - const jsonFiles = entries.filter((f) => f.endsWith(".json")); - - const states = await Promise.all( - jsonFiles.map(async (f) => { - try { - const raw = await readFile(join(dir, f), "utf8"); - return JSON.parse(raw) as SessionState; - } catch { - return null; - } - }), - ); - - return states - .filter((s): s is SessionState => s !== null) - .filter((s) => !filter?.agentId || s.agent_id === filter.agentId) - .filter((s) => !filter?.status || s.status === filter.status) - .sort((a, b) => b.updated_at.localeCompare(a.updated_at)); - } - - async function del(sessionId: string): Promise { - await ready; - try { - await rm(sessionPath(dir, sessionId)); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; - } - } - - return { save, load, list, delete: del }; + const store = createFileJsonStore({ + dir: opts.dir, + idOf: (s) => s.session_id, + compare: (a, b) => b.updated_at.localeCompare(a.updated_at), + }); + + return { + async save(state) { + await store.save({ ...state, updated_at: nowIso() }); + }, + load: (sessionId) => store.load(sessionId), + async list(filter?: { agentId?: string; status?: SessionState["status"] }) { + return store.list((s) => { + if (filter?.agentId !== undefined && s.agent_id !== filter.agentId) return false; + if (filter?.status !== undefined && s.status !== filter.status) return false; + return true; + }); + }, + delete: (sessionId) => store.delete(sessionId), + }; } diff --git a/packages/harness/src/persistence/file-json-store.ts b/packages/harness/src/persistence/file-json-store.ts new file mode 100644 index 00000000..2136d333 --- /dev/null +++ b/packages/harness/src/persistence/file-json-store.ts @@ -0,0 +1,93 @@ +/** + * Generic file-backed JSON store — Phase 32 Wave 2. + * + * Each record is written to /. as pretty JSON. + * Atomic writes via .tmp + rename. Dir created lazily on first use. + */ +import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +export interface FileJsonStoreOptions { + /** Directory under which records are stored. Created if missing. */ + dir: string; + /** File extension without leading dot. Default: "json". */ + ext?: string; + /** Returns the record's file id (e.g., r => r.audit_id). */ + idOf: (record: T) => string; + /** Optional comparator for list() ordering. */ + compare?: (a: T, b: T) => number; +} + +export interface FileJsonStore { + /** Persist a record. Atomic via tmp + rename. */ + save(record: T): Promise; + /** Load a record by id. Returns null when the file does not exist. */ + load(id: string): Promise; + /** List all records, optionally filtered, sorted by compare if supplied. */ + list(filter?: (record: T) => boolean): Promise; + /** Remove a record by id. No-op when the file does not exist. */ + delete(id: string): Promise; +} + +export function createFileJsonStore(opts: FileJsonStoreOptions): FileJsonStore { + const { dir, idOf, compare } = opts; + const ext = opts.ext ?? "json"; + const ready = mkdir(dir, { recursive: true }); + + function filePath(id: string): string { + return join(dir, `${id}.${ext}`); + } + + async function save(record: T): Promise { + await ready; + const id = idOf(record); + const dest = filePath(id); + const tmp = join(dir, `.${id}.${ext}.tmp`); + await writeFile(tmp, JSON.stringify(record, null, 2), "utf-8"); + await rename(tmp, dest); + } + + async function load(id: string): Promise { + await ready; + try { + const raw = await readFile(filePath(id), "utf-8"); + return JSON.parse(raw) as T; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } + } + + async function list(filter?: (record: T) => boolean): Promise { + await ready; + const entries = await readdir(dir); + const jsonFiles = entries.filter((f) => f.endsWith(`.${ext}`) && !f.startsWith(".")); + + const parsed: Array = await Promise.all( + jsonFiles.map(async (f): Promise => { + try { + const raw = await readFile(join(dir, f), "utf-8"); + return JSON.parse(raw) as T; + } catch { + return null; + } + }), + ); + + let records: T[] = parsed.filter((r): r is T => r !== null); + if (filter) records = records.filter(filter); + if (compare) records = records.sort(compare); + return records; + } + + async function del(id: string): Promise { + await ready; + try { + await unlink(filePath(id)); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + } + } + + return { save, load, list, delete: del }; +} diff --git a/packages/harness/src/persistence/index.ts b/packages/harness/src/persistence/index.ts new file mode 100644 index 00000000..3be76f1a --- /dev/null +++ b/packages/harness/src/persistence/index.ts @@ -0,0 +1,8 @@ +/** + * Persistence barrel — Phase 32 Wave 2. + */ +export { + createFileJsonStore, + type FileJsonStore, + type FileJsonStoreOptions, +} from "./file-json-store.js"; diff --git a/packages/harness/tests/file-json-store.test.ts b/packages/harness/tests/file-json-store.test.ts new file mode 100644 index 00000000..6296f28e --- /dev/null +++ b/packages/harness/tests/file-json-store.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for the generic FileJsonStore primitive — Phase 32 Wave 2. + */ +import { mkdtempSync, rmSync, existsSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createFileJsonStore } from "../src/persistence/index.js"; + +interface TestRecord { + id: string; + value: string; + ts: string; +} + +function makeRecord(id: string, value = "v", ts = new Date().toISOString()): TestRecord { + return { id, value, ts }; +} + +let tmpDir: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "fjstore-test-")); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("FileJsonStore", () => { + it("save + load round-trip", async () => { + const store = createFileJsonStore({ dir: join(tmpDir, "data"), idOf: (r) => r.id }); + const rec = makeRecord("r1", "hello"); + await store.save(rec); + const loaded = await store.load("r1"); + expect(loaded).toEqual(rec); + }); + + it("load on missing id returns null", async () => { + const store = createFileJsonStore({ dir: join(tmpDir, "data"), idOf: (r) => r.id }); + expect(await store.load("nonexistent")).toBeNull(); + }); + + it("list returns all records", async () => { + const store = createFileJsonStore({ dir: tmpDir, idOf: (r) => r.id }); + await store.save(makeRecord("a")); + await store.save(makeRecord("b")); + await store.save(makeRecord("c")); + const all = await store.list(); + expect(all).toHaveLength(3); + }); + + it("list with filter returns subset", async () => { + const store = createFileJsonStore({ dir: tmpDir, idOf: (r) => r.id }); + await store.save(makeRecord("x1", "keep")); + await store.save(makeRecord("x2", "drop")); + await store.save(makeRecord("x3", "keep")); + const kept = await store.list((r) => r.value === "keep"); + expect(kept).toHaveLength(2); + expect(kept.map((r) => r.id).sort()).toEqual(["x1", "x3"]); + }); + + it("list with compare returns sorted order", async () => { + const store = createFileJsonStore({ + dir: tmpDir, + idOf: (r) => r.id, + compare: (a, b) => b.ts.localeCompare(a.ts), + }); + await store.save(makeRecord("a", "v", "2024-01-01T00:00:00.000Z")); + await store.save(makeRecord("b", "v", "2024-03-01T00:00:00.000Z")); + await store.save(makeRecord("c", "v", "2024-02-01T00:00:00.000Z")); + const sorted = await store.list(); + expect(sorted.map((r) => r.id)).toEqual(["b", "c", "a"]); + }); + + it("delete removes the file", async () => { + const store = createFileJsonStore({ dir: tmpDir, idOf: (r) => r.id }); + await store.save(makeRecord("del-me")); + await store.delete("del-me"); + expect(await store.load("del-me")).toBeNull(); + }); + + it("delete on missing id is a no-op (does not throw)", async () => { + const store = createFileJsonStore({ dir: tmpDir, idOf: (r) => r.id }); + await expect(store.delete("ghost")).resolves.toBeUndefined(); + }); + + it("mkdir is created automatically when dir is missing", async () => { + const newDir = join(tmpDir, "nested", "deep", "dir"); + const store = createFileJsonStore({ dir: newDir, idOf: (r) => r.id }); + await store.save(makeRecord("auto-mkdir")); + expect(existsSync(newDir)).toBe(true); + expect(await store.load("auto-mkdir")).not.toBeNull(); + }); + + it("atomic write — .tmp file does not appear in list()", async () => { + const store = createFileJsonStore({ dir: tmpDir, idOf: (r) => r.id }); + // Introduce a stray .tmp file to simulate a crashed write + const { writeFile } = await import("node:fs/promises"); + await writeFile(join(tmpDir, ".stray.json.tmp"), "{}", "utf-8"); + await store.save(makeRecord("real")); + const all = await store.list(); + expect(all).toHaveLength(1); + expect(all[0].id).toBe("real"); + }); + + it("list with filter and compare composes correctly", async () => { + const store = createFileJsonStore({ + dir: tmpDir, + idOf: (r) => r.id, + compare: (a, b) => a.id.localeCompare(b.id), + }); + await store.save(makeRecord("z", "keep", "2024-01-01T00:00:00.000Z")); + await store.save(makeRecord("a", "keep", "2024-03-01T00:00:00.000Z")); + await store.save(makeRecord("m", "drop", "2024-02-01T00:00:00.000Z")); + const result = await store.list((r) => r.value === "keep"); + expect(result.map((r) => r.id)).toEqual(["a", "z"]); + }); +}); diff --git a/packages/harness/tests/mcp-client.test.ts b/packages/harness/tests/mcp-client.test.ts index 2ba56cc6..1a5026f6 100644 --- a/packages/harness/tests/mcp-client.test.ts +++ b/packages/harness/tests/mcp-client.test.ts @@ -3,7 +3,13 @@ */ import { describe, it, expect } from "vitest"; import { existsSync } from "node:fs"; -import { buildNameMap, resolveBareToWire, extractBareFromWire } from "../src/mcp-client/name-resolver.js"; +import { + buildNameMap, + resolveBareToWire, + extractBareFromWire, + toCanonicalTool, + unwrapMcpContent, +} from "../src/mcp-client/name-resolver.js"; import { createStdioMCPClient } from "../src/mcp-client/stdio.js"; import type { MCPServerConfig } from "../src/types.js"; @@ -83,6 +89,96 @@ describe("name-resolver", () => { }); }); +// --------------------------------------------------------------------------- +// toCanonicalTool unit tests +// --------------------------------------------------------------------------- + +describe("toCanonicalTool", () => { + const PREFIX = "mcp__plugin_cfa-core_cfa-core__"; + + it("strips the prefix from the tool name", () => { + const result = toCanonicalTool( + { name: `${PREFIX}option_pricer`, description: "Price an option" }, + PREFIX, + ); + expect(result.name).toBe("option_pricer"); + expect(result.description).toBe("Price an option"); + }); + + it("passes the name through unchanged when prefix does not match", () => { + const result = toCanonicalTool({ name: "some_bare_tool" }, PREFIX); + expect(result.name).toBe("some_bare_tool"); + }); + + it("defaults description to empty string when missing", () => { + const result = toCanonicalTool({ name: "a_tool" }, ""); + expect(result.description).toBe(""); + }); + + it("defaults properties to {} when inputSchema is absent", () => { + const result = toCanonicalTool({ name: "a_tool" }, ""); + expect(result.input_schema.properties).toEqual({}); + expect(result.input_schema.required).toBeUndefined(); + }); + + it("propagates required array when present in inputSchema", () => { + const result = toCanonicalTool( + { + name: "a_tool", + inputSchema: { + type: "object", + properties: { x: { type: "number" } }, + required: ["x"], + }, + }, + "", + ); + expect(result.input_schema.required).toEqual(["x"]); + }); +}); + +// --------------------------------------------------------------------------- +// unwrapMcpContent unit tests +// --------------------------------------------------------------------------- + +describe("unwrapMcpContent", () => { + it("parses valid JSON text content to an object", () => { + const result = unwrapMcpContent({ + content: [{ type: "text", text: '{"price":"0.0208"}' }], + }); + expect(result).toEqual({ price: "0.0208" }); + }); + + it("returns the raw string when text content is not valid JSON", () => { + const result = unwrapMcpContent({ + content: [{ type: "text", text: "plain text result" }], + }); + expect(result).toBe("plain text result"); + }); + + it("returns undefined when no text part exists", () => { + const result = unwrapMcpContent({ + content: [{ type: "resource", uri: "file://foo" }], + }); + expect(result).toBeUndefined(); + }); + + it("returns undefined when content array is empty", () => { + const result = unwrapMcpContent({ content: [] }); + expect(result).toBeUndefined(); + }); + + it("skips a leading non-text block and finds the first text part (.find() semantics)", () => { + const result = unwrapMcpContent({ + content: [ + { type: "resource", uri: "file://ignored" }, + { type: "text", text: '{"found":true}' }, + ], + }); + expect(result).toEqual({ found: true }); + }); +}); + // --------------------------------------------------------------------------- // stdio integration test (skipped if plugin dist/server.js is absent) // --------------------------------------------------------------------------- diff --git a/packages/harness/tests/shared-prompt.test.ts b/packages/harness/tests/shared-prompt.test.ts new file mode 100644 index 00000000..1bc901d6 --- /dev/null +++ b/packages/harness/tests/shared-prompt.test.ts @@ -0,0 +1,94 @@ +/** + * shared-prompt.ts governance tests — Phase 32 Wave 2. + * + * Verifies that: + * 1. Each shared constant is non-empty. + * 2. No shared constant contains a literal `${` (template-literal escaping bug). + * 3. Every specialist systemPrompt contains the canonical no-LLM-arithmetic sentence. + * 4. Every specialist systemPrompt mentions the { "input": { ...params... } } envelope. + * 5. Every specialist systemPrompt contains the traceability table mandate. + */ + +import { describe, expect, it } from "vitest"; +import { + QUALITY_GATE_NO_LLM_ARITHMETIC, + SPECIALIST_OPERATING_MODE, + SPECIALIST_PREAMBLE, + TOOL_CALLING_CONVENTION, + TRACEABILITY_TABLE_FOOTER, +} from "../src/agents/specialists/shared-prompt.js"; +import { creditAnalyst } from "../src/agents/specialists/credit.js"; +import { derivativesAnalyst } from "../src/agents/specialists/derivatives.js"; +import { equityAnalyst } from "../src/agents/specialists/equity.js"; +import { esgRegulatoryAnalyst } from "../src/agents/specialists/esg-regulatory.js"; +import { fixedIncomeAnalyst } from "../src/agents/specialists/fixed-income.js"; +import { macroAnalyst } from "../src/agents/specialists/macro.js"; +import { privateMarketsAnalyst } from "../src/agents/specialists/private-markets.js"; +import { quantRiskAnalyst } from "../src/agents/specialists/quant-risk.js"; + +const ALL_SPECIALISTS = [ + creditAnalyst, + derivativesAnalyst, + equityAnalyst, + esgRegulatoryAnalyst, + fixedIncomeAnalyst, + macroAnalyst, + privateMarketsAnalyst, + quantRiskAnalyst, +]; + +describe("shared-prompt constants", () => { + it("SPECIALIST_OPERATING_MODE is non-empty and has no literal ${", () => { + expect(SPECIALIST_OPERATING_MODE.length).toBeGreaterThan(0); + expect(SPECIALIST_OPERATING_MODE).not.toContain("${"); + }); + + it("TOOL_CALLING_CONVENTION is non-empty and has no literal ${", () => { + expect(TOOL_CALLING_CONVENTION.length).toBeGreaterThan(0); + expect(TOOL_CALLING_CONVENTION).not.toContain("${"); + }); + + it("QUALITY_GATE_NO_LLM_ARITHMETIC is non-empty and has no literal ${", () => { + expect(QUALITY_GATE_NO_LLM_ARITHMETIC.length).toBeGreaterThan(0); + expect(QUALITY_GATE_NO_LLM_ARITHMETIC).not.toContain("${"); + }); + + it("TRACEABILITY_TABLE_FOOTER is non-empty and has no literal ${", () => { + expect(TRACEABILITY_TABLE_FOOTER.length).toBeGreaterThan(0); + expect(TRACEABILITY_TABLE_FOOTER).not.toContain("${"); + }); + + it("SPECIALIST_PREAMBLE is non-empty and has no literal ${", () => { + expect(SPECIALIST_PREAMBLE.length).toBeGreaterThan(0); + expect(SPECIALIST_PREAMBLE).not.toContain("${"); + }); +}); + +describe("specialist systemPrompts carry all governance prose", () => { + it.each(ALL_SPECIALISTS)( + "$id: contains canonical no-LLM-arithmetic sentence", + (specialist) => { + expect(specialist.systemPrompt).toContain( + "LLM-generated arithmetic is prohibited" + ); + } + ); + + it.each(ALL_SPECIALISTS)( + '$id: contains { "input": { ...params... } } envelope mention', + (specialist) => { + expect(specialist.systemPrompt).toContain( + '{ "input": { ...params... } }' + ); + } + ); + + it.each(ALL_SPECIALISTS)( + "$id: contains traceability table mandate", + (specialist) => { + expect(specialist.systemPrompt).toContain( + "| # | Tool | Key Inputs | Output |" + ); + } + ); +});