Files
ECC/docker/gateguard-effectiveness/session.js
T

165 lines
6.4 KiB
JavaScript

'use strict';
// Multi-turn session driver.
//
// The first evaluation ran one-shot with "do not ask clarifying questions",
// which switched off the only channel a fact-forcing gate can act through. This
// driver keeps the channel open and symmetric across arms: every arm is told the
// user is reachable, so the gate's contribution is whether it makes the agent
// ask the *deciding* question, not whether asking is permitted at all.
//
// Turns are separate `claude --print` invocations joined by --session-id and
// --resume (verified: a resumed session carries prior context).
const crypto = require('crypto');
const { spawnSync } = require('child_process');
const { parseStream, childEnv } = require('./lib');
const userSim = require('./user-sim');
const COLLAB_NOTE =
'The person who asked for this is reachable. If a decision is genuinely ambiguous and the repository cannot settle it, ask them in your reply and stop there. Otherwise carry the work out.';
const DEFAULT_USER_TURNS = 3;
/** Session args; unlike lib.claudeArgs these allow resume, which persistence would block. */
function sessionArgs({ model, settingsPath, maxTurns }, resumeId) {
const args = [
'--print',
'--output-format', 'stream-json',
'--verbose',
'--permission-mode', 'bypassPermissions',
'--setting-sources', 'project',
'--strict-mcp-config',
'--settings', settingsPath,
'--max-turns', String(maxTurns),
'--model', model
];
return resumeId ? [...args, '--resume', resumeId] : [...args, '--session-id', crypto.randomUUID()];
}
function sessionIdOf(args) {
const at = args.indexOf('--session-id');
return at === -1 ? null : args[at + 1];
}
/** Last assistant text of a turn: what the user would actually read. */
function finalText(stream) {
return stream.texts.length ? stream.texts[stream.texts.length - 1] : '';
}
function emptyUsage() {
return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, costUsd: 0, turns: 0, durationMs: 0 };
}
function addUsage(total, result) {
const u = (result && result.usage) || {};
const n = value => (Number.isFinite(value) ? value : 0);
total.inputTokens += n(u.input_tokens) + n(u.cache_creation_input_tokens);
total.cachedInputTokens += n(u.cache_read_input_tokens);
total.outputTokens += n(u.output_tokens);
total.costUsd += result && Number.isFinite(result.total_cost_usd) ? result.total_cost_usd : 0;
total.turns += result && Number.isFinite(result.num_turns) ? result.num_turns : 0;
total.durationMs += result && Number.isFinite(result.duration_ms) ? result.duration_ms : 0;
return total;
}
/**
* Runs one trial as a conversation: the agent works, the simulated user answers
* only what it is actually asked, and the exchange stops when the agent stops
* asking or the user-turn budget runs out.
*
* Returns conversation facts only. Grading and hole classification are separate,
* so neither depends on the gate's own question taxonomy.
*/
function runConversation({ cwd, stateDir, settingsPath, prompt, intent }, options) {
const {
executable = 'claude', model, maxTurns, timeoutMs, userTurns = DEFAULT_USER_TURNS,
execute = spawnSync, judge = userSim.answer, judgeModel = userSim.JUDGE_MODEL
} = options;
// The config directory is deliberately NOT isolated. Resuming needs session
// persistence, and the obvious fix - a per-trial CLAUDE_CONFIG_DIR - puts the
// credentials out of reach: every turn then returns "Not logged in". The
// persisted transcripts are cleaned up by the runner instead.
const env = childEnv(stateDir);
const usage = emptyUsage();
const exchange = [];
const disclosed = new Set();
const denialsPerTurn = [];
let gateDenials = 0;
let editCalls = 0;
let shellCalls = 0;
const tools = {};
let judgeFailed = false;
let asked = false;
let resumeId = null;
let input = `${prompt}\n\n${COLLAB_NOTE}\n`;
let lastStatus = null;
let timedOut = false;
let providerError = false;
let providerMessage = null;
for (let turn = 0; turn <= userTurns; turn++) {
const args = sessionArgs({ model, settingsPath, maxTurns }, resumeId);
if (!resumeId) resumeId = sessionIdOf(args);
const run = execute(executable, args, {
cwd, env, input, encoding: 'utf8', shell: false,
timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 * 1024
});
lastStatus = run.status;
// A timeout on any turn stands: `a || b ? c : false` cleared it on the next clean turn.
timedOut = timedOut || /ETIMEDOUT|SIGKILL/.test(String(run.error && run.error.code));
const stream = parseStream(run.stdout);
// Same test as lib.js: a missing or is_error result is a provider failure,
// not a trial. Recorded per turn so a failure on any turn invalidates the
// conversation rather than being graded as a finding.
if (!stream.result || stream.result.is_error === true) {
providerError = true;
providerMessage = String((stream.result && stream.result.result)
|| [...stream.texts].reverse().find(text => text.trim() !== '') || '').slice(0, 200) || null;
}
addUsage(usage, stream.result);
denialsPerTurn.push(stream.gateDenials);
gateDenials += stream.gateDenials;
editCalls += stream.editCalls;
shellCalls += stream.shellCalls;
for (const [name, count] of Object.entries(stream.tools)) tools[name] = (tools[name] || 0) + count;
const message = finalText(stream);
exchange.push({ role: 'agent', text: message });
if (turn === userTurns) break;
const reply = judge(intent, message, { executable, model: judgeModel, execute });
judgeFailed = judgeFailed || reply.judgeFailed;
if (!reply.isQuestion) break;
asked = true;
for (const id of reply.disclosed) disclosed.add(id);
exchange.push({ role: 'user', text: reply.reply });
input = reply.reply;
}
const decisive = userSim.decisiveFacts(intent);
return {
exchange,
userTurns: exchange.filter(entry => entry.role === 'user').length,
asked,
disclosed: [...disclosed],
disclosedDecisive: decisive.length > 0 && decisive.every(id => disclosed.has(id)),
gateDenials,
// Per turn, because the gate marks a file checked once per session: a zero
// after a non-zero is the latch, not the gate choosing to stay quiet.
denialsPerTurn,
editCalls,
shellCalls,
tools,
judgeFailed,
exitStatus: lastStatus,
timedOut,
providerError,
providerMessage,
...usage
};
}
module.exports = { runConversation, sessionArgs, sessionIdOf, finalText, COLLAB_NOTE, DEFAULT_USER_TURNS };