117 lines
3.7 KiB
JavaScript
117 lines
3.7 KiB
JavaScript
|
|
import { test } from 'node:test'
|
||
|
|
import assert from 'node:assert/strict'
|
||
|
|
import fs from 'node:fs'
|
||
|
|
import os from 'node:os'
|
||
|
|
import path from 'node:path'
|
||
|
|
|
||
|
|
import { Orchestrator } from '../src/orchestrator.mjs'
|
||
|
|
|
||
|
|
const sink = { write: () => true }
|
||
|
|
|
||
|
|
function makeProject() {
|
||
|
|
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-guard-orch-'))
|
||
|
|
fs.mkdirSync(path.join(project, '.kilocode-loop'), { recursive: true })
|
||
|
|
fs.writeFileSync(path.join(project, 'goal.json'), JSON.stringify({ title: 'Guard goal', stages: [{ id: 's1', title: 'Do work' }] }))
|
||
|
|
return project
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeConfig(project, overrides = {}) {
|
||
|
|
return {
|
||
|
|
project,
|
||
|
|
goal: 'goal.json',
|
||
|
|
goalText: '',
|
||
|
|
iterations: 3,
|
||
|
|
agent: 'code-design',
|
||
|
|
model: '',
|
||
|
|
variant: '',
|
||
|
|
auto: true,
|
||
|
|
sharedContext: false,
|
||
|
|
hitl: 'off',
|
||
|
|
confirm: false,
|
||
|
|
port: 7997,
|
||
|
|
host: '127.0.0.1',
|
||
|
|
dryRun: true,
|
||
|
|
keepOpen: false,
|
||
|
|
quiet: true,
|
||
|
|
color: false,
|
||
|
|
promptExtra: '',
|
||
|
|
maxIterationMinutes: 0,
|
||
|
|
contextWarnTokens: 150000,
|
||
|
|
verify: '',
|
||
|
|
verifyTimeoutMinutes: 30,
|
||
|
|
maxCost: 0,
|
||
|
|
maxTokens: 0,
|
||
|
|
maxStaleIterations: 0,
|
||
|
|
checkpoint: false,
|
||
|
|
guard: true,
|
||
|
|
guardStop: false,
|
||
|
|
maxGuardBlocks: 3,
|
||
|
|
redact: true,
|
||
|
|
review: false,
|
||
|
|
reviewAgent: '',
|
||
|
|
autoFreshTokens: 0,
|
||
|
|
notify: '',
|
||
|
|
syncGoal: true,
|
||
|
|
...overrides,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Runner that emits a destructive bash tool_use only on the requested iteration,
|
||
|
|
* so the loop's runtime guard aborts that session but (by default) keeps going.
|
||
|
|
*/
|
||
|
|
function guardOnceRunner(guardIterations = [1]) {
|
||
|
|
return async ({ prompt, onEvent, signal }) => {
|
||
|
|
const iteration = Number(prompt.match(/iteration (\d+)\//)?.[1] || 1)
|
||
|
|
if (guardIterations.includes(iteration)) {
|
||
|
|
onEvent({ type: 'tool_use', part: { tool: 'bash', state: { input: { command: 'cd /repo && rm -rf src' } } } })
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
sessionID: `ses_iter_${iteration}`,
|
||
|
|
exitCode: signal.aborted ? 130 : 0,
|
||
|
|
error: null,
|
||
|
|
texts: [],
|
||
|
|
reasoning: [],
|
||
|
|
toolCalls: [],
|
||
|
|
stderr: '',
|
||
|
|
durationMs: 5,
|
||
|
|
interrupted: signal.aborted,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
test('a guard hit aborts the session but the run continues to the next iteration', async () => {
|
||
|
|
const project = makeProject()
|
||
|
|
const final = await new Orchestrator(makeConfig(project, { runner: guardOnceRunner([1]), iterations: 2, runId: 'guard-continue' }), { out: sink }).run()
|
||
|
|
|
||
|
|
assert.notEqual(final.status, 'stopped-guard')
|
||
|
|
assert.equal(final.stopReason, null)
|
||
|
|
assert.equal(final.iterations.length, 2)
|
||
|
|
assert.equal(final.iterations[0].guardViolation.id, 'rm-recursive-force')
|
||
|
|
// The safety feedback must reach the next iteration's prompt.
|
||
|
|
const prompt2 = fs.readFileSync(path.join(project, '.kilocode-loop', 'runs', 'guard-continue', 'iterations', '02-prompt.md'), 'utf8')
|
||
|
|
assert.match(prompt2, /Loop safety feedback/)
|
||
|
|
assert.match(prompt2, /rm-recursive-force/)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('--guard-stop restores the hard stop on the first hit', async () => {
|
||
|
|
const project = makeProject()
|
||
|
|
const final = await new Orchestrator(makeConfig(project, { runner: guardOnceRunner(), guardStop: true, runId: 'guard-stop' }), { out: sink }).run()
|
||
|
|
|
||
|
|
assert.equal(final.status, 'stopped-guard')
|
||
|
|
assert.equal(final.stopReason, 'guard')
|
||
|
|
assert.equal(final.iterations.length, 1)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('a persistently blocked agent stops after maxGuardBlocks iterations', async () => {
|
||
|
|
const project = makeProject()
|
||
|
|
const final = await new Orchestrator(
|
||
|
|
makeConfig(project, { runner: guardOnceRunner([1, 2, 3]), maxGuardBlocks: 2, iterations: 5, runId: 'guard-limit' }),
|
||
|
|
{ out: sink },
|
||
|
|
).run()
|
||
|
|
|
||
|
|
assert.equal(final.status, 'stopped-guard')
|
||
|
|
assert.equal(final.stopReason, 'guard')
|
||
|
|
assert.equal(final.iterations.length, 2)
|
||
|
|
})
|