Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
26 changes: 25 additions & 1 deletion lib/ConstitutionalAgentV4.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1472,15 +1473,26 @@ 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(
`SELECT * FROM trinity_tasks
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) {
Expand Down Expand Up @@ -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`); }
}
Expand Down
74 changes: 74 additions & 0 deletions lib/tool-call-logger.js
Original file line number Diff line number Diff line change
@@ -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 };
38 changes: 19 additions & 19 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading