@uchibeke/gallivant 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -60
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +41 -14
- package/bin/promote-bitbucket.mjs +40 -0
- package/bin/task-pr.mjs +9 -0
- package/bin/task-review.mjs +15 -0
- package/docs/README.md +6 -2
- package/docs/agent-setup.md +55 -41
- package/docs/architecture.md +11 -3
- package/docs/bitbucket-promotion.md +69 -0
- package/docs/bitbucket-review.md +330 -0
- package/docs/chains.md +30 -1
- package/docs/codex-cloud.md +7 -0
- package/docs/codex-review.md +50 -0
- package/docs/commands.md +103 -14
- package/docs/config.md +45 -13
- package/docs/execution.md +1 -1
- package/docs/first-party-review.md +266 -0
- package/docs/git-hosts.md +294 -0
- package/docs/guardrails.md +56 -0
- package/docs/install.md +137 -67
- package/docs/launch.md +7 -4
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +3 -2
- package/docs/prd.md +23 -2
- package/docs/preparation.md +2 -2
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +46 -22
- package/docs/task-delivery.md +5 -5
- package/docs/task-profiles.md +537 -0
- package/docs/tasks.md +59 -197
- package/lib/attribution.mjs +7 -3
- package/lib/block.mjs +10 -1
- package/lib/chains.mjs +101 -30
- package/lib/codex_cloud.mjs +2 -1
- package/lib/codex_cloud_input.mjs +15 -6
- package/lib/codex_cloud_result.mjs +1 -1
- package/lib/commands/doctor.mjs +22 -453
- package/lib/commands/init.mjs +281 -28
- package/lib/commands/register.mjs +81 -13
- package/lib/commands/resume.mjs +5 -1
- package/lib/commands/review.mjs +66 -0
- package/lib/commands/serve.mjs +8 -0
- package/lib/commands/start.mjs +30 -11
- package/lib/commands/status.mjs +18 -2
- package/lib/commands/task.mjs +9 -1
- package/lib/commands/up.mjs +19 -11
- package/lib/commands/why.mjs +7 -3
- package/lib/config.mjs +42 -9
- package/lib/context.mjs +28 -21
- package/lib/control.mjs +21 -6
- package/lib/credential.mjs +20 -14
- package/lib/deliverable.mjs +2 -0
- package/lib/events.mjs +96 -53
- package/lib/execution.mjs +132 -11
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- package/lib/fs.mjs +10 -3
- package/lib/gh.mjs +144 -56
- package/lib/git.mjs +133 -32
- package/lib/git_diff.mjs +121 -0
- package/lib/harness.mjs +14 -10
- package/lib/host_contract.mjs +71 -0
- package/lib/host_definitions.mjs +22 -0
- package/lib/hosts/bitbucket.mjs +553 -0
- package/lib/hosts/bitbucket_contract.mjs +57 -0
- package/lib/hosts/bitbucket_credential.mjs +62 -0
- package/lib/hosts/bitbucket_review.mjs +44 -0
- package/lib/hosts/bitbucket_review_producer.mjs +50 -0
- package/lib/hosts/github.mjs +57 -0
- package/lib/hosts/github_contract.mjs +27 -0
- package/lib/hosts/github_doctor.mjs +482 -0
- package/lib/hosts/github_review_summary.mjs +38 -0
- package/lib/hosts/transport_doctor.mjs +35 -0
- package/lib/hosts.mjs +130 -0
- package/lib/learning_qualification.mjs +1 -1
- package/lib/notify.mjs +1 -1
- package/lib/openai_agents_input.mjs +7 -2
- package/lib/openai_agents_sources.mjs +16 -8
- package/lib/plugin_package.mjs +13 -1
- package/lib/pr_operation.mjs +41 -0
- package/lib/prd.mjs +18 -3
- package/lib/preparation_codex.mjs +6 -1
- package/lib/probe.mjs +6 -4
- package/lib/process.mjs +24 -0
- package/lib/promotion.mjs +132 -0
- package/lib/promotion_git.mjs +63 -0
- package/lib/prompt.mjs +16 -13
- package/lib/provider_health.mjs +14 -3
- package/lib/repository.mjs +68 -0
- package/lib/repository_preflight.mjs +11 -0
- package/lib/review_evaluation.mjs +25 -0
- package/lib/review_evidence.mjs +500 -0
- package/lib/review_history.mjs +73 -0
- package/lib/review_image.mjs +145 -0
- package/lib/review_link.mjs +12 -0
- package/lib/review_policy.mjs +132 -0
- package/lib/review_policy_contract.mjs +18 -0
- package/lib/review_state.mjs +13 -0
- package/lib/review_visual.mjs +94 -0
- package/lib/runtime-environment.mjs +6 -0
- package/lib/runtime_node.mjs +3 -2
- package/lib/runtime_state.mjs +39 -5
- package/lib/sources/prd.mjs +6 -3
- package/lib/state.mjs +137 -24
- package/lib/task_completion.mjs +7 -3
- package/lib/task_contract.mjs +201 -27
- package/lib/task_delivery.mjs +9 -2
- package/lib/task_delivery_contract.mjs +13 -9
- package/lib/task_delivery_view.mjs +18 -2
- package/lib/task_environment.mjs +13 -1
- package/lib/task_limits.mjs +13 -0
- package/lib/task_model_config.mjs +99 -0
- package/lib/task_model_route.mjs +243 -0
- package/lib/task_prepare.mjs +21 -8
- package/lib/task_profile.mjs +1875 -0
- package/lib/task_repository_context.mjs +65 -0
- package/lib/tasks.mjs +252 -51
- package/lib/tick.mjs +373 -98
- package/lib/time.mjs +1 -0
- package/lib/tracker/pages.mjs +19 -5
- package/lib/tracker/review.mjs +21 -0
- package/lib/tracker_process.mjs +8 -1
- package/lib/verification_sandbox.mjs +2 -1
- package/lib/work_artifacts.mjs +79 -0
- package/npm-shrinkwrap.json +12 -3
- package/package.json +9 -3
- package/templates/plugin/task-delivery.SKILL.md +17 -10
- package/templates/plugin/task-intake-contract.json +18 -0
- package/templates/plugin/task-profile-requests.mjs +1448 -0
- package/templates/tracker/tracker.css +3 -0
- package/templates/tracker/tracker.js +38 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Operator configuration for the APort-funded intake model route. Pure: no
|
|
2
|
+
// imports, no environment reads at module level, no provider knowledge beyond
|
|
3
|
+
// the two documented request shapes. Nothing here is task-selectable.
|
|
4
|
+
//
|
|
5
|
+
// config.json:
|
|
6
|
+
// "intake_model": {
|
|
7
|
+
// "adapter": "openai-chat" | "anthropic-messages",
|
|
8
|
+
// "model": "<provider model id>",
|
|
9
|
+
// "endpoint": "https://.../v1/chat/completions" | "https://.../v1/messages",
|
|
10
|
+
// "credential_env": "NAME_OF_ENV_VAR_HOLDING_THE_KEY",
|
|
11
|
+
// "budget": { "max_calls": 3, "max_input_tokens": 50000, "max_output_tokens": 6000,
|
|
12
|
+
// "allowance_usd": 0.10, "input_usd_per_million": 0.25, "output_usd_per_million": 2 },
|
|
13
|
+
// "timeout_ms": 60000
|
|
14
|
+
// }
|
|
15
|
+
// Ceilings below are absolute: configuration can lower them, never raise them.
|
|
16
|
+
export const MODEL_ROUTE_CEILINGS = Object.freeze({ maxCalls: 12, maxInputTokens: 100_000, maxOutputTokens: 16_000, allowanceUsd: 10, timeoutMs: 300_000 });
|
|
17
|
+
export const MODEL_ROUTE_ADAPTERS = Object.freeze(["openai-chat", "anthropic-messages"]);
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
19
|
+
|
|
20
|
+
const localHost = host => {
|
|
21
|
+
const h = host.toLowerCase().replace(/\.$/, "");
|
|
22
|
+
if (h.startsWith("[")) return true;
|
|
23
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(h)) {
|
|
24
|
+
const [a, b] = h.split(".").map(Number);
|
|
25
|
+
return a === 0 || a === 10 || a === 127 || a >= 224 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127;
|
|
26
|
+
}
|
|
27
|
+
return !h.includes(".") || /(?:^|\.)(?:localhost|local|internal|test|invalid|example|home\.arpa|onion)$/.test(h);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function intakeModelProblems(raw) {
|
|
31
|
+
const problems = [];
|
|
32
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return ["intake_model must be an object (adapter, model, endpoint, credential_env, budget, timeout_ms)"];
|
|
33
|
+
const extra = Object.keys(raw).filter(key => !["adapter", "model", "endpoint", "credential_env", "budget", "timeout_ms"].includes(key));
|
|
34
|
+
if (extra.length) problems.push(`intake_model has unknown key${extra.length === 1 ? "" : "s"} ${extra.join(", ")}`);
|
|
35
|
+
if (!MODEL_ROUTE_ADAPTERS.includes(raw.adapter)) problems.push(`intake_model.adapter must be one of ${MODEL_ROUTE_ADAPTERS.join(", ")}`);
|
|
36
|
+
if (typeof raw.model !== "string" || !/^[\w.:/-]{1,128}$/.test(raw.model)) problems.push("intake_model.model must be the provider's model identifier (letters, digits, . : / _ -)");
|
|
37
|
+
let endpoint = null;
|
|
38
|
+
try { endpoint = new URL(raw.endpoint); } catch { /* reported below */ }
|
|
39
|
+
if (!endpoint || endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) problems.push("intake_model.endpoint must be an HTTPS URL without credentials, query or fragment");
|
|
40
|
+
else if (localHost(endpoint.hostname)) problems.push("intake_model.endpoint must name a public provider host, not a local or private address");
|
|
41
|
+
if (typeof raw.credential_env !== "string" || !/^[A-Z][A-Z0-9_]{2,63}$/.test(raw.credential_env)) problems.push("intake_model.credential_env must name the environment variable holding the API key (for example OPENAI_API_KEY); keys never belong in config.json");
|
|
42
|
+
const budget = raw.budget;
|
|
43
|
+
if (!budget || typeof budget !== "object" || Array.isArray(budget)) problems.push("intake_model.budget must set max_calls, max_input_tokens, max_output_tokens, allowance_usd, input_usd_per_million and output_usd_per_million");
|
|
44
|
+
else {
|
|
45
|
+
const integer = (key, ceiling) => { const value = budget[key]; if (!Number.isSafeInteger(value) || value < 1 || value > ceiling) problems.push(`intake_model.budget.${key} must be an integer from 1 to ${ceiling}`); };
|
|
46
|
+
integer("max_calls", MODEL_ROUTE_CEILINGS.maxCalls);
|
|
47
|
+
integer("max_input_tokens", MODEL_ROUTE_CEILINGS.maxInputTokens);
|
|
48
|
+
integer("max_output_tokens", MODEL_ROUTE_CEILINGS.maxOutputTokens);
|
|
49
|
+
if (!Number.isFinite(budget.allowance_usd) || budget.allowance_usd <= 0 || budget.allowance_usd > MODEL_ROUTE_CEILINGS.allowanceUsd) problems.push(`intake_model.budget.allowance_usd must be above 0 and at most ${MODEL_ROUTE_CEILINGS.allowanceUsd}`);
|
|
50
|
+
for (const key of ["input_usd_per_million", "output_usd_per_million"]) if (!Number.isFinite(budget[key]) || budget[key] < 0) problems.push(`intake_model.budget.${key} must be the provider's list price per million tokens (0 or more); spend is estimated from it`);
|
|
51
|
+
const extraBudget = Object.keys(budget).filter(key => !["max_calls", "max_input_tokens", "max_output_tokens", "allowance_usd", "input_usd_per_million", "output_usd_per_million"].includes(key));
|
|
52
|
+
if (extraBudget.length) problems.push(`intake_model.budget has unknown key${extraBudget.length === 1 ? "" : "s"} ${extraBudget.join(", ")}`);
|
|
53
|
+
}
|
|
54
|
+
if (raw.timeout_ms !== undefined && (!Number.isSafeInteger(raw.timeout_ms) || raw.timeout_ms < 1000 || raw.timeout_ms > MODEL_ROUTE_CEILINGS.timeoutMs)) problems.push(`intake_model.timeout_ms must be an integer from 1000 to ${MODEL_ROUTE_CEILINGS.timeoutMs}`);
|
|
55
|
+
return problems;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function validateIntakeModelConfig(raw) {
|
|
59
|
+
const problems = intakeModelProblems(raw);
|
|
60
|
+
if (problems.length) throw Object.assign(new Error(problems.join("; ")), { code: "TASK_MODEL_ROUTE" });
|
|
61
|
+
return Object.freeze({ adapter: raw.adapter, model: raw.model, endpoint: raw.endpoint, credentialEnv: raw.credential_env, timeoutMs: raw.timeout_ms ?? DEFAULT_TIMEOUT_MS,
|
|
62
|
+
budget: Object.freeze({ maxCalls: raw.budget.max_calls, maxInputTokens: raw.budget.max_input_tokens, maxOutputTokens: raw.budget.max_output_tokens, allowanceUsd: raw.budget.allowance_usd, inputUsdPerMillion: raw.budget.input_usd_per_million, outputUsdPerMillion: raw.budget.output_usd_per_million }) });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The selection a task pins at creation: everything later turns execute
|
|
66
|
+
// against, minus the credential, which is always read from the environment.
|
|
67
|
+
export function pinnedModelRoute(route) {
|
|
68
|
+
return { adapter: route.adapter, model: route.model, endpoint: route.endpoint, endpointHost: new URL(route.endpoint).hostname, credentialEnv: route.credentialEnv, timeoutMs: route.timeoutMs, budget: { ...route.budget } };
|
|
69
|
+
}
|
|
70
|
+
// Reconstruct and revalidate a saved selection, so a later turn cannot run
|
|
71
|
+
// against fields outside the documented shape or above the ceilings.
|
|
72
|
+
export function savedModelRoute(pinned) {
|
|
73
|
+
if (!pinned || typeof pinned !== "object") throw Object.assign(new Error("the task has no pinned model route; prepare a new task"), { code: "TASK_MODEL_ROUTE" });
|
|
74
|
+
const budget = pinned.budget || {};
|
|
75
|
+
return validateIntakeModelConfig({ adapter: pinned.adapter, model: pinned.model, endpoint: pinned.endpoint, credential_env: pinned.credentialEnv, timeout_ms: pinned.timeoutMs,
|
|
76
|
+
budget: { max_calls: budget.maxCalls, max_input_tokens: budget.maxInputTokens, max_output_tokens: budget.maxOutputTokens, allowance_usd: budget.allowanceUsd, input_usd_per_million: budget.inputUsdPerMillion, output_usd_per_million: budget.outputUsdPerMillion } });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Route availability without secrets. The credential value is never returned;
|
|
80
|
+
// only whether the named variable is present in the operator environment.
|
|
81
|
+
export function intakeModelRoute(config, env = {}) {
|
|
82
|
+
const raw = config?.intake_model;
|
|
83
|
+
if (raw === undefined || raw === null) return { enabled: false, reason: "intake_model is not configured; add it to config.json to enable the APort-funded model route", route: null };
|
|
84
|
+
let route;
|
|
85
|
+
try { route = validateIntakeModelConfig(raw); }
|
|
86
|
+
catch (error) { return { enabled: false, reason: `intake_model configuration is invalid: ${error.message}`, route: null }; }
|
|
87
|
+
const credentialPresent = typeof env?.[route.credentialEnv] === "string" && env[route.credentialEnv].trim().length > 0;
|
|
88
|
+
if (!credentialPresent) return { enabled: false, reason: `export ${route.credentialEnv} in the operator environment to enable the configured ${route.adapter} model route; the key is read at call time and never saved`, route };
|
|
89
|
+
return { enabled: true, reason: null, route };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Public summary for capability discovery and task views: no endpoint secrets,
|
|
93
|
+
// no credential names beyond the variable the operator chose to configure.
|
|
94
|
+
export function intakeModelRouteSummary(config, env = {}) {
|
|
95
|
+
const selection = intakeModelRoute(config, env);
|
|
96
|
+
const host = selection.route ? new URL(selection.route.endpoint).hostname : null;
|
|
97
|
+
return { enabled: selection.enabled, reason: selection.reason, adapter: selection.route?.adapter || null, model: selection.route?.model || null, endpointHost: host,
|
|
98
|
+
budget: selection.route ? { ...selection.route.budget } : null, ceilings: { ...MODEL_ROUTE_CEILINGS }, tools: [], source: "operator-configuration" };
|
|
99
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// The APort-funded intake model route: one bounded, tool-free text request per
|
|
2
|
+
// planning turn against an operator-configured endpoint. It drafts; it never
|
|
3
|
+
// implements, reads the repository, runs commands or publishes. Budget ceilings
|
|
4
|
+
// are absolute and checked before a call is started; spend is reported from
|
|
5
|
+
// provider usage at configured list prices or labelled unknown, never zero.
|
|
6
|
+
import { draftPrompt, MAX_PREPARATION_PROMPT_BYTES } from "./task_contract.mjs";
|
|
7
|
+
import { taskError } from "./task_store.mjs";
|
|
8
|
+
import { redact } from "./execution_io.mjs";
|
|
9
|
+
import { savedModelRoute, MODEL_ROUTE_CEILINGS } from "./task_model_config.mjs";
|
|
10
|
+
|
|
11
|
+
export const MODEL_ROUTE_PROVIDER = "aport-model";
|
|
12
|
+
export const MODEL_ROUTE_BOUNDARY = "aport-model-text-only-v1";
|
|
13
|
+
export const MODEL_ROUTE_LIMITS = Object.freeze({ responseBytes: 1_048_576 });
|
|
14
|
+
// Per-request protocol tokens (role markers, message boundaries) that the
|
|
15
|
+
// serialized body cannot show; counted on top of the byte bound.
|
|
16
|
+
export const MODEL_ROUTE_FRAMING_TOKENS = 64;
|
|
17
|
+
// The smallest deadline worth starting a call with; the configured timeout's
|
|
18
|
+
// own floor. Less remaining task runtime than this refuses before the request.
|
|
19
|
+
export const MODEL_ROUTE_MIN_TIMEOUT_MS = 1000;
|
|
20
|
+
// A receipt qualifies only for the task it was issued to and the route that
|
|
21
|
+
// task pinned, so an attempt copied or restored from another task (or produced
|
|
22
|
+
// by a different adapter/model) can never authorize readiness or dispatch here.
|
|
23
|
+
export const isModelRouteReceipt = (receipt, task = null) => Boolean(receipt && receipt.version === 1 && receipt.provider === MODEL_ROUTE_PROVIDER && receipt.account === "operator"
|
|
24
|
+
&& receipt.boundary === MODEL_ROUTE_BOUNDARY && receipt.authority === "text-only" && Array.isArray(receipt.tools) && receipt.tools.length === 0
|
|
25
|
+
&& (task === null || (receipt.taskId === `intake/${task.id}` && task.route === "model" && receipt.adapter === task.modelRoute?.adapter && receipt.model === task.modelRoute?.model)));
|
|
26
|
+
|
|
27
|
+
// A true upper bound, not an estimate: byte-level BPE fallback can spend one
|
|
28
|
+
// token per UTF-8 byte on emoji, rare scripts or identifier-heavy text, so the
|
|
29
|
+
// pre-call token and spend checks assume exactly that. Reported usage replaces
|
|
30
|
+
// the bound after the call; a provider that reports more than the ceiling is
|
|
31
|
+
// rejected (see below), so the ceiling holds in both directions.
|
|
32
|
+
export const estimateTokens = text => Buffer.byteLength(text);
|
|
33
|
+
export const estimateUsd = (budget, inputTokens, outputTokens) => (inputTokens * budget.inputUsdPerMillion + outputTokens * budget.outputUsdPerMillion) / 1_000_000;
|
|
34
|
+
const usd = value => `$${value.toFixed(4)}`;
|
|
35
|
+
const SYSTEM = "You prepare requirements for a repository task. Reply with exactly one JSON object and nothing else. You have no tools, no repository access and no ability to perform actions; describe, map and ask, never execute.";
|
|
36
|
+
|
|
37
|
+
// Usage must be a pair of non-negative safe integers. Absent usage is honest
|
|
38
|
+
// uncertainty; present but malformed usage (negative, fractional, non-numeric)
|
|
39
|
+
// is a malformed response, never a spend record that could offset the allowance.
|
|
40
|
+
const tokenCount = value => Number.isSafeInteger(value) && value >= 0;
|
|
41
|
+
function usageFrom(usage, inputKey, outputKey) {
|
|
42
|
+
if (usage === undefined || usage === null) return { usage: null };
|
|
43
|
+
if (typeof usage !== "object" || Array.isArray(usage) || !tokenCount(usage[inputKey]) || !tokenCount(usage[outputKey])) return { usage: null, usageError: `provider usage is malformed (${inputKey}/${outputKey} must be non-negative integers)` };
|
|
44
|
+
return { usage: { inputTokens: usage[inputKey], outputTokens: usage[outputKey] } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Two documented request shapes. Neither admits tools, functions, web search or
|
|
48
|
+
// file access; the request body is built here and only here.
|
|
49
|
+
const ADAPTERS = {
|
|
50
|
+
"openai-chat": {
|
|
51
|
+
request: ({ route, key, prompt }) => ({ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
52
|
+
body: { model: route.model, messages: [{ role: "system", content: SYSTEM }, { role: "user", content: prompt }], max_tokens: route.budget.maxOutputTokens, temperature: 0 } }),
|
|
53
|
+
parse: json => ({ text: json?.choices?.[0]?.message?.content, finish: json?.choices?.[0]?.finish_reason ?? null, truncated: json?.choices?.[0]?.finish_reason === "length",
|
|
54
|
+
...usageFrom(json?.usage, "prompt_tokens", "completion_tokens") }),
|
|
55
|
+
},
|
|
56
|
+
"anthropic-messages": {
|
|
57
|
+
request: ({ route, key, prompt }) => ({ headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json" },
|
|
58
|
+
body: { model: route.model, max_tokens: route.budget.maxOutputTokens, system: SYSTEM, messages: [{ role: "user", content: prompt }] } }),
|
|
59
|
+
parse: json => ({ text: Array.isArray(json?.content) ? json.content.filter(block => block?.type === "text" && typeof block.text === "string").map(block => block.text).join("") : undefined, finish: json?.stop_reason ?? null, truncated: json?.stop_reason === "max_tokens",
|
|
60
|
+
...usageFrom(json?.usage, "input_tokens", "output_tokens") }),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const failed = attempt => taskError(attempt.errorCode || "TASK_MODEL_ROUTE", attempt.error || "saved model route call failed");
|
|
65
|
+
|
|
66
|
+
// Cancelling a body is awaited only until the deadline: a provider stream
|
|
67
|
+
// whose cancel() never settles must not hold the preparation lock past the
|
|
68
|
+
// configured timeout. A synchronous throw or a non-promise counts as settled.
|
|
69
|
+
async function cancelWithin(cancel, signal) {
|
|
70
|
+
let pending;
|
|
71
|
+
try { pending = cancel(); } catch { return; }
|
|
72
|
+
if (!pending || typeof pending.then !== "function" || signal?.aborted) return;
|
|
73
|
+
await new Promise(resolve => {
|
|
74
|
+
const done = () => { signal?.removeEventListener("abort", done); resolve(); };
|
|
75
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
76
|
+
pending.then(done, done);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The response limit is enforced while the body streams, so a chunked or
|
|
81
|
+
// unlabelled oversized reply is cancelled at the limit instead of buffered.
|
|
82
|
+
// Reading happens under the caller's abort signal, so the configured timeout
|
|
83
|
+
// still covers a provider that sends headers and then stalls.
|
|
84
|
+
async function boundedJson(response, signal) {
|
|
85
|
+
const limit = MODEL_ROUTE_LIMITS.responseBytes;
|
|
86
|
+
const oversized = () => new Error("provider response exceeds the bounded limit");
|
|
87
|
+
if (Number(response.headers?.get?.("content-length") || 0) > limit) { await cancelWithin(() => response.body?.cancel?.(), signal); throw oversized(); }
|
|
88
|
+
let text;
|
|
89
|
+
const reader = typeof response.body?.getReader === "function" ? response.body.getReader() : null;
|
|
90
|
+
if (reader) {
|
|
91
|
+
const chunks = [];
|
|
92
|
+
let size = 0;
|
|
93
|
+
const abort = () => reader.cancel().catch(() => {});
|
|
94
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
95
|
+
try {
|
|
96
|
+
for (;;) {
|
|
97
|
+
const aborted = () => { throw Object.assign(new Error("aborted"), { name: "AbortError" }); };
|
|
98
|
+
if (signal?.aborted) aborted();
|
|
99
|
+
const { value, done } = await reader.read();
|
|
100
|
+
// Cancelling the reader at the deadline resolves the pending read as
|
|
101
|
+
// done; a body that never completed must never parse as complete.
|
|
102
|
+
if (signal?.aborted) aborted();
|
|
103
|
+
if (done) break;
|
|
104
|
+
size += value.length;
|
|
105
|
+
if (size > limit) throw oversized();
|
|
106
|
+
chunks.push(value);
|
|
107
|
+
}
|
|
108
|
+
if (signal?.aborted) throw Object.assign(new Error("aborted"), { name: "AbortError" });
|
|
109
|
+
text = Buffer.concat(chunks).toString("utf8");
|
|
110
|
+
} finally {
|
|
111
|
+
signal?.removeEventListener("abort", abort);
|
|
112
|
+
await cancelWithin(() => reader.cancel(), signal);
|
|
113
|
+
reader.releaseLock();
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
text = typeof response.text === "function" ? await response.text() : "";
|
|
117
|
+
if (Buffer.byteLength(text) > limit) throw oversized();
|
|
118
|
+
}
|
|
119
|
+
try { return JSON.parse(text); } catch { throw new Error("provider returned invalid JSON"); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Same contract as prepareTurn: returns the planner's final message, records
|
|
123
|
+
// usage and cost on the attempt, and throws bounded task errors. An attempt is
|
|
124
|
+
// persisted before the request so a crash cannot purchase a second call.
|
|
125
|
+
export async function prepareModelTurn(ctx, task, store, failures, { fetcher = ctx.fetch ?? globalThis.fetch, clock = Date } = {}) {
|
|
126
|
+
const env = ctx.env ?? process.env;
|
|
127
|
+
delete task.preflightFailure; // A fresh failure must earn its own pre-call marker.
|
|
128
|
+
// Later turns execute against the route pinned when the task was created,
|
|
129
|
+
// so an operator configuration change applies to new tasks and the review
|
|
130
|
+
// describes the route that produced the current draft. Only the credential
|
|
131
|
+
// is read live, from the variable the pinned route names.
|
|
132
|
+
let route;
|
|
133
|
+
try { route = savedModelRoute(task.modelRoute); }
|
|
134
|
+
catch (error) { throw taskError("TASK_MODEL_ROUTE", `${error.message}; no model call was made`); }
|
|
135
|
+
let attempt = task.attempts.at(-1);
|
|
136
|
+
if (attempt?.settled && !attempt.consumed) {
|
|
137
|
+
if (!attempt.ok) throw failed(attempt);
|
|
138
|
+
if (attempt.route !== "model" || !isModelRouteReceipt(attempt.receipt, task)) throw taskError("TASK_PREPARATION_BOUNDARY", "saved output was not produced by this task's text-only model route; it cannot authorize readiness; inspect the saved attempt and prepare a new task");
|
|
139
|
+
return attempt.message;
|
|
140
|
+
}
|
|
141
|
+
if (attempt && !attempt.settled) throw taskError("TASK_PREPARATION_UNKNOWN", "a model route call is recorded without a settled result; inspect the saved attempt before retrying; no new model call was made");
|
|
142
|
+
// Only a new request needs the credential. Replaying settled output above
|
|
143
|
+
// never does, so a temporarily absent variable cannot block recovery.
|
|
144
|
+
if (typeof env?.[route.credentialEnv] !== "string" || !env[route.credentialEnv].trim()) {
|
|
145
|
+
// This known local preflight owns no new request or spend. Explicit resume
|
|
146
|
+
// may retry it; submitted, failed and unsettled calls never get this marker.
|
|
147
|
+
task.preflightFailure = { code: "TASK_MODEL_ROUTE", stage: "model-credential", attemptCount: task.attempts.length, validationFailures: [...failures] };
|
|
148
|
+
store.save(task);
|
|
149
|
+
throw taskError("TASK_MODEL_ROUTE", `export ${route.credentialEnv} in the operator environment to continue this task's pinned ${route.adapter} model route, then explicitly resume it; the key is read at call time and never saved; no model call was made`);
|
|
150
|
+
}
|
|
151
|
+
const calls = task.attempts.filter(a => a.route === "model");
|
|
152
|
+
if (calls.length >= route.budget.maxCalls || calls.length >= MODEL_ROUTE_CEILINGS.maxCalls) throw taskError("TASK_ALLOWANCE", `the model route allows ${Math.min(route.budget.maxCalls, MODEL_ROUTE_CEILINGS.maxCalls)} calls per task and ${calls.length} were used; inspect the saved draft and its validation failures, then answer, clarify or prepare a new task; no further model call was made`);
|
|
153
|
+
// The task's runtime allowance bounds this call as well as its dollars: the
|
|
154
|
+
// deadline is capped to what remains, and a remainder too small for any
|
|
155
|
+
// call refuses before the request rather than overrunning the allowance.
|
|
156
|
+
const usedMs = task.attempts.reduce((sum, a) => sum + (a.elapsedMs || 0), 0);
|
|
157
|
+
const remainingMs = (task.allowance?.runtimeMs ?? Infinity) - usedMs;
|
|
158
|
+
if (remainingMs < MODEL_ROUTE_MIN_TIMEOUT_MS) throw taskError("TASK_ALLOWANCE", `the task runtime allowance has ${Math.max(0, Math.floor(remainingMs))} ms left, below the ${MODEL_ROUTE_MIN_TIMEOUT_MS} ms a model call needs; no model call was made; inspect the saved evidence or prepare a new task with a larger allowance`);
|
|
159
|
+
const timeoutMs = Math.min(route.timeoutMs, Math.floor(remainingMs));
|
|
160
|
+
const prompt = draftPrompt(task, failures);
|
|
161
|
+
if (Buffer.byteLength(prompt) > MAX_PREPARATION_PROMPT_BYTES) throw taskError("TASK_INPUT", `serialized preparation prompt exceeds ${MAX_PREPARATION_PROMPT_BYTES} bytes; select fewer or smaller context files, or prepare a smaller task; saved requirements were retained`);
|
|
162
|
+
const key = env[route.credentialEnv];
|
|
163
|
+
const endpointHost = new URL(route.endpoint).hostname;
|
|
164
|
+
const adapter = ADAPTERS[route.adapter];
|
|
165
|
+
const { headers, body } = adapter.request({ route, key, prompt });
|
|
166
|
+
// Bound the complete billed request: the serialized body covers the system
|
|
167
|
+
// message, the prompt and every framing field, and a fixed margin covers the
|
|
168
|
+
// provider's per-message protocol tokens that are not in the body at all.
|
|
169
|
+
const inputTokens = estimateTokens(JSON.stringify(body)) + MODEL_ROUTE_FRAMING_TOKENS;
|
|
170
|
+
if (inputTokens > route.budget.maxInputTokens) throw taskError("TASK_INPUT", `the intake prompt is about ${inputTokens} tokens; the model route input limit is ${route.budget.maxInputTokens} (ceiling ${MODEL_ROUTE_CEILINGS.maxInputTokens}); select fewer or smaller documents or prepare a smaller task; no model call was made`);
|
|
171
|
+
const reservedUsd = estimateUsd(route.budget, inputTokens, route.budget.maxOutputTokens);
|
|
172
|
+
// A reported cost replaces its pre-call reservation; an unknown cost keeps
|
|
173
|
+
// the reservation as the conservative bound.
|
|
174
|
+
const spent = calls.reduce((sum, a) => sum + (a.cost ?? a.reservedUsd ?? 0), 0);
|
|
175
|
+
const allowance = Math.min(route.budget.allowanceUsd, MODEL_ROUTE_CEILINGS.allowanceUsd, task.allowance?.budgetUsd ?? Infinity);
|
|
176
|
+
if (spent + reservedUsd > allowance) throw taskError("TASK_ALLOWANCE", `the next model call could cost up to ${usd(reservedUsd)} at the configured list prices (about ${inputTokens} input and up to ${route.budget.maxOutputTokens} output tokens); ${usd(spent)} of the ${usd(allowance)} model allowance is already spent or reserved, so the call was not started. Select less context, split the request, or raise intake_model.budget.allowance_usd up to ${usd(MODEL_ROUTE_CEILINGS.allowanceUsd)}. Estimates are not provider invoices.`);
|
|
177
|
+
const receipt = { version: 1, provider: MODEL_ROUTE_PROVIDER, account: "operator", boundary: MODEL_ROUTE_BOUNDARY, authority: "text-only", tools: [], adapter: route.adapter, model: route.model, taskId: `intake/${task.id}` };
|
|
178
|
+
attempt = { name: `model-${task.attempts.length + 1}`, route: "model", adapter: route.adapter, model: route.model, endpointHost, receipt, boundary: MODEL_ROUTE_BOUNDARY, reservedUsd,
|
|
179
|
+
estimate: { inputTokens, outputTokens: route.budget.maxOutputTokens, usd: reservedUsd, basis: "configured list prices; not a provider invoice" },
|
|
180
|
+
startedAt: clock.now(), deadline: clock.now() + timeoutMs, timeoutMs, settled: false, consumed: false, ok: null, cost: null, usage: null, spend: { status: "reserved", usd: reservedUsd, basis: "pre-call reservation at configured list prices" } };
|
|
181
|
+
task.attempts.push(attempt);
|
|
182
|
+
task.status = "preparing";
|
|
183
|
+
store.save(task); // Intent precedes the request; an unknown outcome never buys a second call.
|
|
184
|
+
const settle = (ok, patch) => { Object.assign(attempt, patch, { ok, settled: true, endedAt: clock.now() }); attempt.elapsedMs = Math.max(0, attempt.endedAt - attempt.startedAt); store.save(task); };
|
|
185
|
+
const controller = new AbortController();
|
|
186
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
187
|
+
// One deadline covers the request and the whole response body: a provider
|
|
188
|
+
// that answers headers and then stalls mid-stream is aborted like a connect
|
|
189
|
+
// timeout, so the preparation mutation and its task lock cannot hang.
|
|
190
|
+
const timedOut = error => error?.name === "AbortError" || controller.signal.aborted;
|
|
191
|
+
let response;
|
|
192
|
+
try {
|
|
193
|
+
response = await fetcher(route.endpoint, { method: "POST", headers, body: JSON.stringify(body), signal: controller.signal, redirect: "error" });
|
|
194
|
+
} catch (error) {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
settle(false, { errorCode: "TASK_MODEL_ROUTE", error: `model route request to ${endpointHost} failed: ${timedOut(error) ? `timed out after ${timeoutMs} ms` : redact(String(error?.message || error), [key])}; billing for this call is unknown and its reservation of ${usd(reservedUsd)} is retained`, spend: { status: "unknown", usd: null, reservedUsd, reason: "no provider usage was received" } });
|
|
197
|
+
throw failed(attempt);
|
|
198
|
+
}
|
|
199
|
+
if (!response?.ok) {
|
|
200
|
+
// The error body's cancellation runs under the same deadline, so a
|
|
201
|
+
// provider whose cancel() never settles cannot hold the task lock.
|
|
202
|
+
try { await cancelWithin(() => response?.body?.cancel?.(), controller.signal); } finally { clearTimeout(timer); }
|
|
203
|
+
settle(false, { errorCode: "TASK_MODEL_ROUTE", error: `model route HTTP ${response?.status ?? "unknown"} from ${endpointHost}; check intake_model.endpoint, intake_model.model and ${route.credentialEnv}; billing for this call is unknown and its reservation of ${usd(reservedUsd)} is retained`, spend: { status: "unknown", usd: null, reservedUsd, reason: `HTTP ${response?.status ?? "unknown"}` } });
|
|
204
|
+
throw failed(attempt);
|
|
205
|
+
}
|
|
206
|
+
let parsed;
|
|
207
|
+
try { parsed = adapter.parse(await boundedJson(response, controller.signal)); }
|
|
208
|
+
catch (error) {
|
|
209
|
+
// A body error keeps its own reason even when the deadline passed while its
|
|
210
|
+
// cancellation was being abandoned; only an aborted read is a timeout.
|
|
211
|
+
const reason = error?.name === "AbortError" ? `the response body did not complete within ${timeoutMs} ms` : error.message;
|
|
212
|
+
settle(false, { errorCode: "TASK_MODEL_ROUTE", error: `model route response from ${endpointHost} was unusable: ${reason}; billing for this call is unknown and its reservation of ${usd(reservedUsd)} is retained`, spend: { status: "unknown", usd: null, reservedUsd, reason } });
|
|
213
|
+
throw failed(attempt);
|
|
214
|
+
} finally { clearTimeout(timer); }
|
|
215
|
+
if (parsed.usageError) {
|
|
216
|
+
settle(false, { errorCode: "TASK_MODEL_ROUTE", error: `model route response from ${endpointHost} was unusable: ${parsed.usageError}; billing for this call is unknown and its reservation of ${usd(reservedUsd)} is retained`, spend: { status: "unknown", usd: null, reservedUsd, reason: parsed.usageError } });
|
|
217
|
+
throw failed(attempt);
|
|
218
|
+
}
|
|
219
|
+
const usage = parsed.usage;
|
|
220
|
+
const cost = usage ? estimateUsd(route.budget, usage.inputTokens, usage.outputTokens) : null;
|
|
221
|
+
const spend = usage ? { status: "reported", usd: cost, usage, basis: "provider-reported usage at configured list prices; not a provider invoice" } : { status: "unknown", usd: null, reservedUsd, reason: "the provider reported no usage; the pre-call reservation remains the spend bound" };
|
|
222
|
+
if (usage && usage.outputTokens > route.budget.maxOutputTokens) {
|
|
223
|
+
settle(false, { usage, cost, spend, errorCode: "TASK_MODEL_ROUTE", error: `the provider reported ${usage.outputTokens} output tokens, above the configured limit of ${route.budget.maxOutputTokens}; the draft was rejected` });
|
|
224
|
+
throw failed(attempt);
|
|
225
|
+
}
|
|
226
|
+
if (usage && usage.inputTokens > route.budget.maxInputTokens) {
|
|
227
|
+
settle(false, { usage, cost, spend, errorCode: "TASK_MODEL_ROUTE", error: `the provider reported ${usage.inputTokens} input tokens, above the configured limit of ${route.budget.maxInputTokens} despite the pre-call byte bound; the draft was rejected and the route needs operator review` });
|
|
228
|
+
throw failed(attempt);
|
|
229
|
+
}
|
|
230
|
+
if (typeof parsed.text !== "string" || !parsed.text.trim()) {
|
|
231
|
+
settle(false, { usage, cost, spend, errorCode: "TASK_MODEL_ROUTE", error: "the provider returned no text draft; the call is recorded and no implementation was started" });
|
|
232
|
+
throw failed(attempt);
|
|
233
|
+
}
|
|
234
|
+
if (parsed.truncated) {
|
|
235
|
+
settle(false, { usage, cost, spend, errorCode: "TASK_MODEL_ROUTE", error: `the draft stopped at the configured output limit of ${route.budget.maxOutputTokens} tokens and is incomplete; prepare a smaller intake or select fewer documents` });
|
|
236
|
+
throw failed(attempt);
|
|
237
|
+
}
|
|
238
|
+
// Valid reported usage replaces the pre-call reservation: the implementation
|
|
239
|
+
// allowance follows the reported spend, not the ceiling that was held while
|
|
240
|
+
// the call was in flight (that ceiling stays on `estimate` for the record).
|
|
241
|
+
settle(true, { usage, cost, ...(usage ? { spend: { ...spend, releasedReservationUsd: reservedUsd }, reservedUsd: null } : { spend }), finish: parsed.finish, message: redact(parsed.text, [key]), error: null });
|
|
242
|
+
return attempt.message;
|
|
243
|
+
}
|
package/lib/task_prepare.mjs
CHANGED
|
@@ -7,15 +7,24 @@ import { taskRoot, taskError } from "./task_store.mjs";
|
|
|
7
7
|
import { draftPrompt, MAX_TURNS, MAX_PREPARATION_PROMPT_BYTES } from "./task_contract.mjs";
|
|
8
8
|
import { executionRunner } from "./execution.mjs";
|
|
9
9
|
import { redact } from "./execution_io.mjs";
|
|
10
|
+
import { isModelRouteReceipt } from "./task_model_route.mjs";
|
|
10
11
|
|
|
11
12
|
export const PREPARATION_PENDING = Symbol("preparation-pending");
|
|
12
|
-
// Only
|
|
13
|
-
// timeout after submit has uncertain ownership and must reconcile
|
|
14
|
-
export const preparationPreflightRetryable = task =>
|
|
15
|
-
|
|
16
|
-
&&
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
// Only known pre-submit deadline, availability or credential failures carry a recovery
|
|
14
|
+
// marker. A timeout after submit has uncertain ownership and must reconcile.
|
|
15
|
+
export const preparationPreflightRetryable = task => {
|
|
16
|
+
const marker = task.preflightFailure;
|
|
17
|
+
const marked = Boolean(marker && marker.code === task.errorCode && marker.attemptCount === task.attempts.length);
|
|
18
|
+
const model = task.route === "model" && task.errorCode === "TASK_MODEL_ROUTE"
|
|
19
|
+
&& ((marked && marker.stage === "model-credential")
|
|
20
|
+
// Older pre-call failures have no marker. Successful, consumed receipts
|
|
21
|
+
// prove there is no provider outcome to replay or uncertain call to retry.
|
|
22
|
+
|| (!marker && task.attempts.every(a => a.ok && isModelRouteReceipt(a.receipt, task))));
|
|
23
|
+
const implementation = marked && task.errorCode === "EXECUTION_UNAVAILABLE" && marker.stage === "implementation-availability";
|
|
24
|
+
return ((marked && task.errorCode === "EXECUTION_DEADLINE") || model || implementation)
|
|
25
|
+
&& task.attempts.every(attempt => attempt.settled && attempt.consumed)
|
|
26
|
+
&& !task.dispatchIntent && !task.implementation;
|
|
27
|
+
};
|
|
19
28
|
export const preparationCancellationPending = task => {
|
|
20
29
|
const attempt = task.attempts.at(-1);
|
|
21
30
|
return !!attempt && ((attempt.cancellation && !["confirmed", "terminal"].includes(attempt.cancellation.status)) ||
|
|
@@ -40,7 +49,11 @@ const failed = attempt => {
|
|
|
40
49
|
};
|
|
41
50
|
|
|
42
51
|
const boundaryFailure = () => taskError("TASK_PREPARATION_BOUNDARY", "saved preparation used an unverified tool boundary; its output cannot authorize readiness or dispatch; use a hand-authored PRD with ready/start until an isolated preparation adapter is supported");
|
|
43
|
-
|
|
52
|
+
// Model-route attempts carry their own text-only receipt; they never borrow a
|
|
53
|
+
// harness boundary, and a harness attempt never qualifies through the route.
|
|
54
|
+
const safeAttempt = (task, attempt, capabilities, account) => attempt.route === "model"
|
|
55
|
+
? isModelRouteReceipt(attempt.receipt, task)
|
|
56
|
+
: isPreparationReceipt(task.harness, attempt.receipt ?? attempt.boundary, capabilities, account)
|
|
44
57
|
&& (!attempt.receipt?.taskId || attempt.receipt.taskId === `intake/${task.id}`);
|
|
45
58
|
export function assertPreparationBoundary(task, ctx = {}) {
|
|
46
59
|
const capabilities = ctx.capabilitiesFor?.(task.harness) || capabilitiesFor(task.harness);
|