feat(implement-loop): adaptive plan extraction — batch small plans, guarded
The per-task plan-extract fan-out re-primes a fresh context per task (~59k real tokens each; 244 dispatches / 14.4M in the 206-run corpus) to re-read the same plan. Small plans now extract in ONE plan-extract-all call, gated by per-task byte estimates the index reports (ceiling 20k bytes — on BYTES, never task count: the #22 truncation is output-byte-driven). The #22 protection is strengthened, not traded: the batched result must carry EXACTLY the expected id set (count match, every id present, no empty text) or it is discarded wholesale and the per-task fan-out runs instead — a prefix-truncated batch drops trailing tasks and fails the count-match closed. No single-shot retry; the fan-out is the retry. Estimates are a routing heuristic only, never load-bearing for correctness. refs #29, refs #22
This commit is contained in:
@@ -230,10 +230,41 @@ const TASK_INDEX_SCHEMA = {
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id', 'title'],
|
||||
required: ['id', 'title', 'approx_bytes'],
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
title: { type: 'string', description: 'one-line title ONLY — never the task body' },
|
||||
approx_bytes: {
|
||||
type: 'integer',
|
||||
description:
|
||||
"rough byte length of this task's verbatim body in the plan (headers + prose + code blocks). An " +
|
||||
'ESTIMATE for choosing the extraction strategy only — never load-bearing for correctness; when in ' +
|
||||
'doubt, estimate HIGH (an overestimate merely keeps the per-task fan-out).',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Batched extraction carrier (issue #29): every requested task's verbatim
|
||||
// block in ONE response — used only under the byte ceiling below, with a
|
||||
// deterministic completeness guard and a fan-out fallback (issue #22 is the
|
||||
// reason this is adaptive, not unconditional).
|
||||
const TASK_TEXTS_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['tasks'],
|
||||
properties: {
|
||||
tasks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id', 'title', 'text'],
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
title: { type: 'string' },
|
||||
text: { type: 'string', description: 'verbatim task block from the plan (this one task only)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -567,12 +598,59 @@ if (mode === 'mini') {
|
||||
expectedIds = enumeratedIds
|
||||
}
|
||||
|
||||
// Per-task verbatim extraction — read-only and order-independent, so fan out
|
||||
// via parallel(). The requested id is captured in the closure and returned
|
||||
// alongside the result, so association is by id, never by resolution order.
|
||||
// parallel() yields null for a thunk that threw / whose agent died; agent()
|
||||
// itself returns null on a terminal error — both surface as a missing id below.
|
||||
const extractedTasks = await parallel(
|
||||
// Extraction strategy (issue #29, guarded against issue #22): the per-task
|
||||
// fan-out re-primes a fresh context per task (~59k real tokens each in the
|
||||
// 206-run corpus) to re-read the SAME plan — for a small plan that is pure
|
||||
// priming overhead. Extraction is therefore adaptive on the index's byte
|
||||
// estimates:
|
||||
// • total estimate <= EXTRACT_ALL_BYTE_CEILING → ONE extract-all call
|
||||
// carries every requested task's verbatim block. The ceiling is on
|
||||
// BYTES, never task count — the #22 truncation (a schema-bound response
|
||||
// self-limits to a well-formed PREFIX) is output-byte-driven: a 5-task
|
||||
// plan with long code blocks can truncate where 10 one-liner tasks would
|
||||
// not. The estimates are agent-reported, so they are a ROUTING heuristic
|
||||
// only; correctness rests on the deterministic completeness guard below:
|
||||
// the batched result must carry EXACTLY the expected id set (count
|
||||
// match, every id present, no empty text) or it is discarded WHOLESALE
|
||||
// and the fan-out runs instead — one bounded wasted call, never a
|
||||
// truncated run. (A prefix-truncated batch drops trailing tasks, so the
|
||||
// count-match catches it; association keys on the agent's self-reported
|
||||
// ids, and the exact-set requirement makes a mislabelled response fail
|
||||
// closed.) No single-shot retry: the fan-out IS the retry, and it is the
|
||||
// truncation-proof path.
|
||||
// • above the ceiling, estimates missing/implausible, or a failed
|
||||
// extract-all → the per-task fan-out, unchanged: read-only and
|
||||
// order-independent, requested id bound in the closure and returned
|
||||
// alongside the result, so association is by id, never by resolution
|
||||
// order. parallel() yields null for a thunk that threw / whose agent
|
||||
// died; agent() itself returns null on a terminal error — both surface
|
||||
// as a missing id below.
|
||||
const EXTRACT_ALL_BYTE_CEILING = 20000
|
||||
const approxById = new Map()
|
||||
for (const t of index.task_ids) if (!approxById.has(t.id)) approxById.set(t.id, t.approx_bytes)
|
||||
const estimates = expectedIds.map((id) => approxById.get(id))
|
||||
const totalApprox = estimates.every((b) => Number.isInteger(b) && b >= 0)
|
||||
? estimates.reduce((s, b) => s + b, 0)
|
||||
: null
|
||||
let extractedTasks = null
|
||||
if (expectedIds.length > 1 && totalApprox !== null && totalApprox <= EXTRACT_ALL_BYTE_CEILING) {
|
||||
const all = await agent(
|
||||
`${STANDING}\n\nRead the plan at ${plan_path}. Extract EXACTLY these tasks (1-based ids): ${expectedIds.join(', ')} — ` +
|
||||
'EACH as its VERBATIM block: the full task text as written, including any code blocks. Do not summarise, ' +
|
||||
'truncate, reorder, or merge tasks into each other. Return exactly those tasks, nothing else. Do not edit anything.',
|
||||
{ model: 'sonnet', effort: 'medium', label: 'plan-extract-all', phase: 'Per-task loop', schema: TASK_TEXTS_SCHEMA },
|
||||
)
|
||||
const returned = all && Array.isArray(all.tasks) ? all.tasks : []
|
||||
const byId = new Map()
|
||||
for (const t of returned) {
|
||||
if (t && Number.isInteger(t.id) && typeof t.text === 'string' && t.text.trim() && !byId.has(t.id)) byId.set(t.id, t)
|
||||
}
|
||||
if (returned.length === expectedIds.length && expectedIds.every((id) => byId.has(id))) {
|
||||
extractedTasks = expectedIds.map((id) => ({ reqId: id, t: byId.get(id) }))
|
||||
}
|
||||
}
|
||||
if (!extractedTasks) {
|
||||
extractedTasks = await parallel(
|
||||
expectedIds.map((id) => () =>
|
||||
agent(
|
||||
`${STANDING}\n\nRead the plan at ${plan_path}. Extract EXACTLY task ${id} (1-based) as its VERBATIM block — ` +
|
||||
@@ -582,6 +660,7 @@ if (mode === 'mini') {
|
||||
).then((t) => ({ reqId: id, t })),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Guard (c) — every expected id must have produced non-empty verbatim text.
|
||||
// A null result (thunk threw, or agent died) or empty text is the truncation
|
||||
|
||||
Reference in New Issue
Block a user