diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..7e2cf156 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +# S-QUORUM Phase 7 — CI gate for trinity-symphony-shared. +# +# The agent code has no jest setup; tests are plain `node tests/*.test.js` scripts. +# This gate (a) syntax-checks the core agent libs and (b) runs the node test suite. +# escalation-contract.test.js is jest-style (uses jest.fn()) and is intentionally +# excluded — it can't run under bare node. Make this a required check in branch +# protection (Settings → Branches) once green. +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install deps + run: npm ci || npm install + + - name: Syntax-check core agent libs + run: | + node -c lib/ConstitutionalAgentV4.js + node -c lib/tool-call-logger.js + node -c lib/getNextTask.js 2>/dev/null || true + + - name: Node tests + run: | + node tests/getNextTask.test.js + node tests/provenance.test.js + node tests/pulseCheckExecutor.test.js diff --git a/lib/ConstitutionalAgentV4.js b/lib/ConstitutionalAgentV4.js index 402fb737..35888765 100644 --- a/lib/ConstitutionalAgentV4.js +++ b/lib/ConstitutionalAgentV4.js @@ -20,6 +20,7 @@ const { validateArtifactQuality } = require('./artifactGuard'); const substanceGateClient = require('./substance-gate-client'); const { withTimeout } = require('./withTimeout'); const { pgQuery, pgPing } = require('./direct-pg'); +const { logToolCall } = require('./tool-call-logger'); // S-QUORUM Phase 4 (gated TOOL_CALL_LOGGING) // Sprint 14 R-1 — per-call timeout budgets for awaits inside the loop body. // Picked from Sprint 13 diagnostic: production observed LLM <10s, Supabase <1s. @@ -1472,6 +1473,16 @@ CRITERIA: ${task.success_criteria} // statuses, unclaimed, priority DESC then oldest-first. No LIMIT — the // blacklist filter below needs the full candidate set. retries:1 (the // runLoop call sites still wrap getNextTask() in withTimeout). RULE-8. + // S-QUORUM Phase 5 — capability-based claiming filter (gated CAPABILITY_FILTER, default OFF). + // Agents shadow_reject task types they have no handler for (cait/EVERGREEN ≈ 84% of rejects, + // per S-HARDEN), wasting claim/release cycles. When enabled, the query only returns task + // types this agent can handle; a NULL task_type is always allowed. Default off => capFilter + // is null => the predicate is a no-op (byte-identical to prior behavior). + const CAPABILITY_FILTER = process.env.CAPABILITY_FILTER === 'true'; + const HANDLED = (process.env.AGENT_TASK_TYPES || + 'peer_verify,review,meta,system,research,code,docs,artifact,critique') + .split(',').map(s => s.trim()).filter(Boolean); + const capFilter = CAPABILITY_FILTER ? HANDLED : null; let tasks; try { tasks = await pgQuery( @@ -1479,8 +1490,9 @@ CRITERIA: ${task.success_criteria} WHERE (assigned_to = $1 OR (assigned_to IS NULL AND (agent_assigned = $1 OR agent_assigned IS NULL))) AND status = ANY($2) AND claimed_by IS NULL + AND ($3::text[] IS NULL OR task_type IS NULL OR task_type = ANY($3)) ORDER BY priority DESC, created_at ASC`, - [this.name, ['pending', 'todo', 'assigned', 'pending_clarification']], + [this.name, ['pending', 'todo', 'assigned', 'pending_clarification'], capFilter], { retries: 1, timeoutMs: LOOP_TIMEOUTS.DB_QUERY, label: 'getNextTask' } ); } catch (error) { @@ -2097,6 +2109,18 @@ Provide your reasoning and analysis first, and end with the [VERDICT] line.`; try { const res = await this.callProvider(provider, prompt); this.sessionMetrics.llmCalls++; + // S-QUORUM Phase 4 — audit the LLM tool call. Fire-and-forget (never awaited so it + // adds no latency) and never throws; no-op unless TOOL_CALL_LOGGING=true. + logToolCall({ + agentName: this.name, + toolName: `llm:${canonicalizeProvider(providerKey)}:${provider.model}`, + toolInput: { prompt_sha256: require('crypto').createHash('sha256').update(String(prompt)).digest('hex'), provider: canonicalizeProvider(providerKey), model: provider.model }, + toolOutput: res, + repidAtCall: this.reputationScore || 0, + confidenceAtCall: 0.9, + autonomyTier: 'just_do_it', + hitlRequired: false, + }); return { ...res, provider: canonicalizeProvider(providerKey) }; } catch (e) { console.warn(`${providerKey} failed`); } } diff --git a/lib/tool-call-logger.js b/lib/tool-call-logger.js new file mode 100644 index 00000000..5b9eba8c --- /dev/null +++ b/lib/tool-call-logger.js @@ -0,0 +1,74 @@ +/** + * tool_call_log writer for the swarm agents (S-QUORUM Phase 4). + * + * Mirrors repid-engine's src/utils/tool-call-logger.ts: appends a row to the hash-chained + * `tool_call_log` (a BEFORE-INSERT trigger stamps previous_entry_hash). OFF by default — set + * TOOL_CALL_LOGGING=true to enable. Never throws: a logging failure must not break an agent. + * + * Uses the same direct-pg pooler the agent hot paths use. tool_input is stored as jsonb; + * tool_output is hashed (sha256), never stored raw. + */ +const crypto = require('crypto'); +const { pgQuery } = require('./direct-pg'); + +function isToolCallLoggingEnabled() { + return process.env.TOOL_CALL_LOGGING === 'true'; +} + +/** + * Append a row to tool_call_log. No-op unless TOOL_CALL_LOGGING=true. Never throws. + * @param {object} p + * @param {string} p.agentName + * @param {string} p.toolName + * @param {*} p.toolInput stored as jsonb (truncated defensively) + * @param {*} p.toolOutput hashed, not stored + * @param {number} [p.repidAtCall] + * @param {number} [p.confidenceAtCall] 0..1 + * @param {string} [p.autonomyTier] 'just_do_it' | 'do_then_tell' | 'ask_first' + * @param {boolean} [p.hitlRequired] + * @param {string|null} [p.hitlDecision] + */ +async function logToolCall(p) { + if (!isToolCallLoggingEnabled()) return; + try { + const outputHash = crypto.createHash('sha256') + .update(JSON.stringify(p.toolOutput ?? null)) + .digest('hex'); + + let inputJson; + try { + inputJson = JSON.stringify(p.toolInput ?? null).slice(0, 10000); + } catch { + inputJson = JSON.stringify({ note: 'unserializable tool_input' }); + } + + const confidence = Number.isFinite(p.confidenceAtCall) + ? Math.max(0, Math.min(1, p.confidenceAtCall)) + : null; + const repid = Number.isFinite(p.repidAtCall) ? Math.round(p.repidAtCall) : null; + + await pgQuery( + `INSERT INTO tool_call_log + (agent_name, tool_name, tool_input, tool_output_hash, repid_at_call, + confidence_at_call, autonomy_tier, hitl_required, hitl_decision) + VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, $8, $9)`, + [ + p.agentName, + p.toolName, + inputJson, + outputHash, + repid, + confidence, + p.autonomyTier ?? null, + p.hitlRequired ?? false, + p.hitlDecision ?? null, + ], + { retries: 1, timeoutMs: 8000, label: 'tool_call_log.insert' } + ); + } catch (err) { + // Never crash an agent over an audit-log write. + console.error('[tool-call-log] failed:', err && err.message ? err.message : err); + } +} + +module.exports = { logToolCall, isToolCallLoggingEnabled }; diff --git a/package-lock.json b/package-lock.json index ce375df0..c90e2766 100644 --- a/package-lock.json +++ b/package-lock.json @@ -203,9 +203,9 @@ "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -216,7 +216,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -488,14 +488,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -514,7 +514,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -1006,9 +1006,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/pg": { @@ -1153,9 +1153,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -1288,13 +1288,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4"