factory-ai 1.4.0 → 1.5.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/CHANGELOG.md +16 -4
- package/Dockerfile.worker +1 -1
- package/README.md +8 -2
- package/ROADMAP.md +0 -1
- package/bin/factory +118 -1
- package/bootstrap/agent-factory-worker.service +2 -2
- package/bootstrap/auto-update.sh +20 -12
- package/bootstrap/deploy-runtime.sh +5 -3
- package/bootstrap/factory-ai-container-firewall.service +14 -0
- package/bootstrap/factory-ai-snapshot.service +1 -0
- package/bootstrap/factory-ai-watchdog.service +21 -0
- package/bootstrap/factory-ai-watchdog.timer +11 -0
- package/bootstrap/setup.sh +20 -9
- package/cmd/factory-ui/main.go +455 -0
- package/go.mod +39 -0
- package/go.sum +82 -0
- package/package.json +7 -3
- package/scripts/test.js +7 -0
- package/src/acp-adapter.js +26 -0
- package/src/acp-cli.js +16 -0
- package/src/activity.js +96 -0
- package/src/agent-executor.js +39 -5
- package/src/agent-runner.js +30 -10
- package/src/approval-policy.js +12 -0
- package/src/azure-harness.js +47 -14
- package/src/bedrock-harness.js +45 -14
- package/src/config.js +9 -0
- package/src/container-runner.js +65 -12
- package/src/context-compaction.js +16 -0
- package/src/control-plane.js +77 -16
- package/src/control-service.js +6 -1
- package/src/dashboard.js +25 -6
- package/src/extension-cli.js +11 -0
- package/src/extension-manifest.js +76 -0
- package/src/hooks.js +62 -0
- package/src/instructions.js +45 -0
- package/src/mcp-tools.js +19 -3
- package/src/objective-status.js +16 -0
- package/src/operator-logs.js +24 -0
- package/src/operator.js +31 -9
- package/src/process.js +3 -3
- package/src/release-gate.js +1 -0
- package/src/release-service.js +4 -1
- package/src/repo-map.js +128 -0
- package/src/reporter.js +11 -0
- package/src/retriever.js +18 -18
- package/src/routing.js +11 -1
- package/src/scanner-suite.js +4 -3
- package/src/snapshot.js +16 -0
- package/src/task-entry.js +28 -6
- package/src/task-graph.js +0 -5
- package/src/telegram-service.js +7 -1
- package/src/telegram.js +5 -1
- package/src/telemetry.js +173 -0
- package/src/tui.js +27 -8
- package/src/validation.js +41 -9
- package/src/watchdog.js +62 -0
- package/src/worker.js +32 -3
- package/src/workspace-tools.js +13 -3
- package/src/workspace.js +22 -2
- package/templates/AGENTS.md +15 -0
package/src/activity.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const identifier = /^[A-Za-z0-9_-]{1,64}$/;
|
|
5
|
+
const terminal = new Set(["succeeded", "failed", "cancelled"]);
|
|
6
|
+
|
|
7
|
+
export function isStaleActivity(activity, taskStatus, now = new Date(), staleSeconds = 120) {
|
|
8
|
+
if (!activity?.occurredAt || terminal.has(taskStatus)) return false;
|
|
9
|
+
return now.getTime() - new Date(activity.occurredAt).getTime() > staleSeconds * 1000;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class ActivityStore {
|
|
13
|
+
constructor(stateRoot) {
|
|
14
|
+
this.root = path.join(stateRoot, "activity");
|
|
15
|
+
this.maxFileBytes = 5_000_000;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
directory(objectiveId) {
|
|
19
|
+
if (!identifier.test(objectiveId)) throw new Error("Invalid objective activity ID");
|
|
20
|
+
return path.join(this.root, objectiveId);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async withTaskLock(objectiveId, taskId, operation) {
|
|
24
|
+
if (!identifier.test(taskId)) throw new Error("Invalid task activity ID");
|
|
25
|
+
const directory = this.directory(objectiveId);
|
|
26
|
+
await mkdir(directory, { recursive: true, mode: 0o750 });
|
|
27
|
+
const lock = path.join(directory, `${taskId}.lock`);
|
|
28
|
+
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
29
|
+
try {
|
|
30
|
+
await mkdir(lock, { mode: 0o750 });
|
|
31
|
+
try { return await operation(); } finally { await rm(lock, { recursive: true, force: true }); }
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code !== "EEXIST") throw error;
|
|
34
|
+
try {
|
|
35
|
+
const metadata = await stat(lock);
|
|
36
|
+
if (Date.now() - metadata.mtimeMs > 30_000) { await rm(lock, { recursive: true, force: true }); continue; }
|
|
37
|
+
} catch (statError) { if (statError.code !== "ENOENT") throw statError; }
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Timed out acquiring activity lock for ${taskId}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async append(objectiveId, taskId, event) {
|
|
45
|
+
return this.withTaskLock(objectiveId, taskId, async () => {
|
|
46
|
+
const file = path.join(this.directory(objectiveId), `${taskId}.jsonl`);
|
|
47
|
+
try {
|
|
48
|
+
const metadata = await stat(file);
|
|
49
|
+
if (metadata.size > this.maxFileBytes) {
|
|
50
|
+
const content = await readFile(file, "utf8");
|
|
51
|
+
const retained = content.slice(-Math.floor(this.maxFileBytes / 2));
|
|
52
|
+
const boundary = retained.indexOf("\n");
|
|
53
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
54
|
+
await writeFile(temporary, boundary >= 0 ? retained.slice(boundary + 1) : retained, { mode: 0o640 });
|
|
55
|
+
await rename(temporary, file);
|
|
56
|
+
}
|
|
57
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
58
|
+
const value = { ...event, occurredAt: event.occurredAt ?? new Date().toISOString() };
|
|
59
|
+
await appendFile(file, `${JSON.stringify(value)}\n`, { mode: 0o640 });
|
|
60
|
+
return value;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async latestTask(objectiveId, taskId) {
|
|
65
|
+
if (!identifier.test(taskId)) throw new Error("Invalid task activity ID");
|
|
66
|
+
let lines;
|
|
67
|
+
try { lines = (await readFile(path.join(this.directory(objectiveId), `${taskId}.jsonl`), "utf8")).split("\n").filter(Boolean); } catch (error) { if (error.code === "ENOENT") return undefined; throw error; }
|
|
68
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
69
|
+
try { const event = JSON.parse(lines[index]); if (event.type !== "telemetry.recorded") return event; } catch {}
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async latestObjective(objectiveId) {
|
|
75
|
+
const directory = this.directory(objectiveId);
|
|
76
|
+
let files;
|
|
77
|
+
try { files = await readdir(directory); } catch (error) { if (error.code === "ENOENT") return {}; throw error; }
|
|
78
|
+
const result = {};
|
|
79
|
+
for (const file of files.filter((name) => name.endsWith(".jsonl"))) {
|
|
80
|
+
const lines = (await readFile(path.join(directory, file), "utf8")).split("\n").filter(Boolean);
|
|
81
|
+
let latest;
|
|
82
|
+
let retryCount = 0;
|
|
83
|
+
let lastError;
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
try {
|
|
86
|
+
const event = JSON.parse(line);
|
|
87
|
+
if (event.type !== "telemetry.recorded") latest = event;
|
|
88
|
+
if (event.type === "model.retry") retryCount += 1;
|
|
89
|
+
if (event.error || event.type?.endsWith(".failed")) lastError = event.error ?? event.status;
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
if (latest) result[file.slice(0, -6)] = { ...latest, retryCount, lastError };
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/agent-executor.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { parseTaskResult } from "./validation.js";
|
|
2
|
+
import { buildRepositoryMap } from "./repo-map.js";
|
|
3
|
+
import { runHooks } from "./hooks.js";
|
|
2
4
|
|
|
3
5
|
export class AgentExecutor {
|
|
4
|
-
constructor({ workspaces, agentRunner, scannerSuite, retriever, sendControl }) {
|
|
6
|
+
constructor({ workspaces, agentRunner, scannerSuite, retriever, sendControl, buildRepoMap = buildRepositoryMap, repoMapMaxCharacters = 8000, hooks = [], hookHandlers = {} }) {
|
|
5
7
|
this.workspaces = workspaces;
|
|
6
8
|
this.agentRunner = agentRunner;
|
|
7
9
|
this.scannerSuite = scannerSuite;
|
|
8
10
|
this.retriever = retriever;
|
|
9
11
|
this.sendControl = sendControl;
|
|
12
|
+
this.buildRepoMap = buildRepoMap;
|
|
13
|
+
this.repoMapMaxCharacters = repoMapMaxCharacters;
|
|
14
|
+
this.hooks = hooks;
|
|
15
|
+
this.hookHandlers = hookHandlers;
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
async process(message) {
|
|
@@ -17,9 +23,15 @@ export class AgentExecutor {
|
|
|
17
23
|
|
|
18
24
|
async processPlanning(message) {
|
|
19
25
|
const directory = await this.workspaces.ensureObjective(message.objective);
|
|
26
|
+
let repoMap = { text: "", entries: [] };
|
|
27
|
+
try { repoMap = await this.buildRepoMap(directory, message.objective.objective, { maxCharacters: this.repoMapMaxCharacters }); } catch {}
|
|
20
28
|
let semanticContext = "";
|
|
21
|
-
try { semanticContext = this.retriever ? await this.retriever.context(directory, message.objective.repository, message.objective.objective) : ""; } catch {}
|
|
22
|
-
const context = [
|
|
29
|
+
try { semanticContext = this.retriever ? await this.retriever.context(directory, message.objective.repository, message.objective.objective, { repositoryEntries: repoMap.entries }) : ""; } catch {}
|
|
30
|
+
const context = [
|
|
31
|
+
...(message.context ?? []),
|
|
32
|
+
...(repoMap.text ? [{ type: "repository-map", content: repoMap.text }] : []),
|
|
33
|
+
...(semanticContext ? [{ type: "local-semantic-retrieval", content: semanticContext }] : []),
|
|
34
|
+
];
|
|
23
35
|
const delivery = await this.agentRunner.plan(message.objective, directory, context);
|
|
24
36
|
await this.sendControl({ type: "planning_result", objectiveId: message.objectiveId, delivery });
|
|
25
37
|
}
|
|
@@ -33,14 +45,22 @@ export class AgentExecutor {
|
|
|
33
45
|
const scannerEvidence = message.task.role === "security" && this.scannerSuite
|
|
34
46
|
? await this.scannerSuite.scan(directory)
|
|
35
47
|
: [];
|
|
48
|
+
const recoveryContext = this.workspaces.recoveryContext ? await this.workspaces.recoveryContext(directory) : "";
|
|
49
|
+
let repoMap = { text: "", entries: [] };
|
|
50
|
+
if (message.task.role !== "release") {
|
|
51
|
+
const query = `${message.objective.objective}\n${message.task.title}\n${message.task.instructions}`;
|
|
52
|
+
try { repoMap = await this.buildRepoMap(directory, query, { maxCharacters: this.repoMapMaxCharacters }); } catch {}
|
|
53
|
+
}
|
|
36
54
|
let semanticContext = "";
|
|
37
55
|
if (message.task.role !== "release" && this.retriever) {
|
|
38
56
|
const query = `${message.objective.objective}\n${message.task.title}\n${message.task.instructions}`;
|
|
39
|
-
try { semanticContext = await this.retriever.context(directory, message.objective.repository, query); } catch {}
|
|
57
|
+
try { semanticContext = await this.retriever.context(directory, message.objective.repository, query, { repositoryEntries: repoMap.entries }); } catch {}
|
|
40
58
|
}
|
|
41
59
|
const prompt = [
|
|
42
60
|
"Execute only this assigned task, verify it, and report factual outcomes.",
|
|
61
|
+
...(recoveryContext ? [recoveryContext] : []),
|
|
43
62
|
...(scannerEvidence.length ? [`TRUSTED SCANNER EVIDENCE (mechanical output; do not claim checks beyond this evidence):\n${JSON.stringify(scannerEvidence)}`] : []),
|
|
63
|
+
...(repoMap.text ? [repoMap.text] : []),
|
|
44
64
|
...(semanticContext ? [`LOCAL SEMANTIC CONTEXT (retrieved from this exact repository revision):\n${semanticContext}`] : []),
|
|
45
65
|
].join("\n\n");
|
|
46
66
|
const result = parseTaskResult(await this.agentRunner.invoke({
|
|
@@ -49,7 +69,20 @@ export class AgentExecutor {
|
|
|
49
69
|
directory,
|
|
50
70
|
prompt,
|
|
51
71
|
}));
|
|
52
|
-
const
|
|
72
|
+
const hookResults = await runHooks(this.hooks, "before_checkpoint", this.hookHandlers, { message, directory, result });
|
|
73
|
+
for (const hook of hookResults) {
|
|
74
|
+
if (hook.action === "scanner" && hook.result.some((item) => item.status !== "passed")) throw new Error("Configured scanner hook did not pass");
|
|
75
|
+
if (hook.action === "policy_check" && hook.result.required && !hook.result.approvalId && !hook.result.skipped) throw new Error("Policy hook required approval but did not create a durable request");
|
|
76
|
+
}
|
|
77
|
+
if (hookResults.some((item) => ["approval_request", "policy_check"].includes(item.action) && item.result?.approvalId)) return;
|
|
78
|
+
const authoring = ["builder", "debugger"].includes(message.task.role);
|
|
79
|
+
if (authoring && this.scannerSuite) {
|
|
80
|
+
const secretScan = await this.scannerSuite.scan(directory, { names: ["gitleaks"] });
|
|
81
|
+
if (secretScan.some((item) => item.status !== "passed")) throw new Error("Pre-push secret scan did not pass");
|
|
82
|
+
}
|
|
83
|
+
const checkpoint = authoring
|
|
84
|
+
? await this.workspaces.checkpoint(directory, message.objective, message.task)
|
|
85
|
+
: await this.workspaces.reference(directory, message.objective, message.task);
|
|
53
86
|
await this.sendControl({
|
|
54
87
|
type: "result",
|
|
55
88
|
objectiveId: message.objectiveId,
|
|
@@ -57,6 +90,7 @@ export class AgentExecutor {
|
|
|
57
90
|
status: "succeeded",
|
|
58
91
|
...result,
|
|
59
92
|
...checkpoint,
|
|
93
|
+
...(scannerEvidence.length ? { scannerEvidence } : {}),
|
|
60
94
|
});
|
|
61
95
|
}
|
|
62
96
|
}
|
package/src/agent-runner.js
CHANGED
|
@@ -5,6 +5,7 @@ import { modelForTask } from "./routing.js";
|
|
|
5
5
|
import { createWorkspaceTools } from "./workspace-tools.js";
|
|
6
6
|
import { connectMcpTools } from "./mcp-tools.js";
|
|
7
7
|
import { BedrockHarness } from "./bedrock-harness.js";
|
|
8
|
+
import { loadRepositoryInstructions } from "./instructions.js";
|
|
8
9
|
|
|
9
10
|
function parseJson(text) {
|
|
10
11
|
const fenced = text.match(/```json\s*([\s\S]*?)```/i)?.[1];
|
|
@@ -16,7 +17,9 @@ function parseJson(text) {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
function endpointForRoute(route, role, environment) {
|
|
19
|
-
const
|
|
20
|
+
const separator = route.indexOf("/");
|
|
21
|
+
const provider = route.slice(0, separator);
|
|
22
|
+
const model = route.slice(separator + 1);
|
|
20
23
|
const lightweight = provider === "azureai-responses";
|
|
21
24
|
const baseUrl = lightweight ? environment.AZURE_OPENAI_BASE_URL : environment.TEXTVED_AZURE_BASE_URL;
|
|
22
25
|
const apiKey = lightweight ? environment.AZURE_OPENAI_API_KEY : environment.TEXTVED_AZURE_API_KEY;
|
|
@@ -38,48 +41,62 @@ export class AzureAgentRunner {
|
|
|
38
41
|
environment = process.env,
|
|
39
42
|
createHarness = (options) => new AzureResponsesHarness(options),
|
|
40
43
|
createBedrockHarness = (options) => new BedrockHarness(options),
|
|
44
|
+
eventSink = () => {},
|
|
41
45
|
} = {}) {
|
|
42
46
|
this.config = config;
|
|
43
47
|
this.registry = registry;
|
|
44
48
|
this.environment = environment;
|
|
45
49
|
this.createHarness = createHarness;
|
|
46
50
|
this.createBedrockHarness = createBedrockHarness;
|
|
51
|
+
this.eventSink = eventSink;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
async promptForTask(objective, task, prompt, capabilities) {
|
|
54
|
+
async promptForTask(objective, task, prompt, capabilities, directory) {
|
|
50
55
|
const factoryName = this.environment.FACTORY_NAME ?? "Factory AI";
|
|
51
56
|
const factoryPurpose = this.environment.FACTORY_PURPOSE ?? "Ship secure reviewed software continuously";
|
|
52
57
|
const skills = await Promise.all(capabilities.filter((item) => item.type === "skill").map(async (item) => (
|
|
53
58
|
`ALLOWLISTED SKILL ${item.name}@${item.version}:\n${await readFile(item.path, "utf8")}`
|
|
54
59
|
)));
|
|
60
|
+
const repositoryInstructions = await loadRepositoryInstructions(directory);
|
|
55
61
|
return [
|
|
56
62
|
`You are a ${factoryName} isolated ${task.role} subagent. Factory purpose: ${factoryPurpose}.`,
|
|
57
63
|
"Work only in the assigned repository. Never inspect credentials, push Git refs, deploy, or install global tools.",
|
|
58
64
|
"Use tools for evidence. Make the smallest correct change and verify every completion claim.",
|
|
59
65
|
'Return only JSON: {"summary":"concise outcome","checks":["command/result"],"risks":["remaining risk"],"approval":"approved|changes_requested|not_applicable"}.',
|
|
60
|
-
...skills,
|
|
61
66
|
`CEO objective: ${objective.objective}`,
|
|
62
67
|
task.instructions,
|
|
63
68
|
prompt,
|
|
69
|
+
...skills,
|
|
70
|
+
repositoryInstructions,
|
|
64
71
|
].join("\n\n");
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
harness(task, directory, additionalTools = {}) {
|
|
68
75
|
const role = task.role;
|
|
69
76
|
const route = modelForTask(task, this.environment);
|
|
70
|
-
const workspaceTools = createWorkspaceTools(directory);
|
|
71
|
-
if (!["builder", "debugger"].includes(role)) delete workspaceTools.write_file;
|
|
77
|
+
const workspaceTools = createWorkspaceTools(directory, { mutable: ["builder", "debugger"].includes(role), allowTests: role === "tester" });
|
|
72
78
|
const tools = { ...workspaceTools, ...additionalTools };
|
|
73
79
|
const budget = budgetFor(task);
|
|
80
|
+
const contextBudget = {
|
|
81
|
+
compactAfterInputTokens: Number(this.environment.FACTORY_COMPACT_AFTER_INPUT_TOKENS ?? 80_000),
|
|
82
|
+
compactMaxCharacters: Number(this.environment.FACTORY_COMPACT_MAX_CHARACTERS ?? 24_000),
|
|
83
|
+
};
|
|
74
84
|
if (route.startsWith("bedrock/")) {
|
|
75
85
|
return this.createBedrockHarness({
|
|
76
86
|
region: this.environment.AWS_REGION ?? "us-east-1",
|
|
77
87
|
model: route.slice("bedrock/".length),
|
|
88
|
+
credentials: this.environment.AWS_ACCESS_KEY_ID && this.environment.AWS_SECRET_ACCESS_KEY ? {
|
|
89
|
+
accessKeyId: this.environment.AWS_ACCESS_KEY_ID,
|
|
90
|
+
secretAccessKey: this.environment.AWS_SECRET_ACCESS_KEY,
|
|
91
|
+
...(this.environment.AWS_SESSION_TOKEN ? { sessionToken: this.environment.AWS_SESSION_TOKEN } : {}),
|
|
92
|
+
} : undefined,
|
|
78
93
|
tools,
|
|
79
94
|
...budget,
|
|
95
|
+
...contextBudget,
|
|
96
|
+
onEvent: this.eventSink,
|
|
80
97
|
});
|
|
81
98
|
}
|
|
82
|
-
return this.createHarness({ ...endpointForRoute(route, role, this.environment), tools, timeoutMs: this.config.timeoutMs, ...budget });
|
|
99
|
+
return this.createHarness({ ...endpointForRoute(route, role, this.environment), tools, timeoutMs: this.config.timeoutMs, ...budget, ...contextBudget, onEvent: this.eventSink });
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
async invoke({ objective, task, directory, prompt }) {
|
|
@@ -87,9 +104,9 @@ export class AzureAgentRunner {
|
|
|
87
104
|
const mcp = await connectMcpTools(capabilities);
|
|
88
105
|
const started = Date.now();
|
|
89
106
|
try {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
);
|
|
107
|
+
const fullPrompt = await this.promptForTask(objective, task, prompt, capabilities, directory);
|
|
108
|
+
const immutableContext = `Factory safety restrictions remain authoritative.\nCEO objective: ${objective.objective}\nAssigned ${task.role} task: ${task.instructions}\n${prompt}`;
|
|
109
|
+
const response = await this.harness(task, directory, mcp.tools).run(fullPrompt, { immutableContext });
|
|
93
110
|
return {
|
|
94
111
|
...parseJson(response.text),
|
|
95
112
|
telemetry: {
|
|
@@ -117,6 +134,7 @@ export class AzureAgentRunner {
|
|
|
117
134
|
...Object.entries(this.registry.skills ?? {}),
|
|
118
135
|
...Object.entries(this.registry.mcp ?? {}),
|
|
119
136
|
].map(([name, item]) => [name, { version: item.version, roles: item.roles }]));
|
|
137
|
+
const repositoryInstructions = await loadRepositoryInstructions(directory);
|
|
120
138
|
const prompt = `You are the ${this.environment.FACTORY_NAME ?? "Factory AI"} planner subagent. Factory purpose: ${this.environment.FACTORY_PURPOSE ?? "Ship secure reviewed software continuously"}. Decompose objectives into the smallest executable DAG.
|
|
121
139
|
Allowed roles: scout, builder, tester, debugger, reviewer, security, release.
|
|
122
140
|
Allowed capabilities: ${JSON.stringify(registrySummary)}
|
|
@@ -125,11 +143,13 @@ Return only JSON: {"executiveIntent":"...","tasks":[{"id":"...","role":"...","ti
|
|
|
125
143
|
|
|
126
144
|
${plannerSkills.join("\n\n")}
|
|
127
145
|
|
|
146
|
+
${repositoryInstructions}
|
|
147
|
+
|
|
128
148
|
Objective: ${objective.objective}
|
|
129
149
|
Verified prior project context: ${JSON.stringify(projectContext).slice(0, 12000)}`;
|
|
130
150
|
const mcp = await connectMcpTools(plannerCapabilities);
|
|
131
151
|
try {
|
|
132
|
-
const response = await this.harness({ role: "planner", complexity: "complex" }, directory, mcp.tools).run(prompt);
|
|
152
|
+
const response = await this.harness({ role: "planner", complexity: "complex" }, directory, mcp.tools).run(prompt, { immutableContext: `Factory safety restrictions remain authoritative.\nPlanner objective: ${objective.objective}` });
|
|
133
153
|
return parseJson(response.text);
|
|
134
154
|
} finally {
|
|
135
155
|
await mcp.close();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const approvalPolicies = Object.freeze([
|
|
2
|
+
"network_expansion",
|
|
3
|
+
"new_dependencies",
|
|
4
|
+
"infrastructure_changes",
|
|
5
|
+
"secret_metadata_changes",
|
|
6
|
+
"external_side_effects",
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export function evaluateApprovalPolicy(signals = {}) {
|
|
10
|
+
const policies = approvalPolicies.filter((policy) => signals[policy] === true);
|
|
11
|
+
return { required: policies.length > 0, policies };
|
|
12
|
+
}
|
package/src/azure-harness.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { compactCheckpoint } from "./context-compaction.js";
|
|
2
|
+
|
|
1
3
|
const TRANSIENT_STATUS = new Set([408, 409, 429, 500, 502, 503, 504]);
|
|
2
4
|
|
|
3
5
|
function outputText(response) {
|
|
@@ -32,6 +34,9 @@ export class AzureResponsesHarness {
|
|
|
32
34
|
timeoutMs = 180_000,
|
|
33
35
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
34
36
|
userAgent = `factory-ai/${process.env.FACTORY_VERSION ?? "dev"}`,
|
|
37
|
+
onEvent = () => {},
|
|
38
|
+
compactAfterInputTokens = 80_000,
|
|
39
|
+
compactMaxCharacters = 24_000,
|
|
35
40
|
}) {
|
|
36
41
|
this.endpoint = `${baseUrl.replace(/\/$/, "")}/responses`;
|
|
37
42
|
this.apiKey = apiKey;
|
|
@@ -44,6 +49,9 @@ export class AzureResponsesHarness {
|
|
|
44
49
|
this.timeoutMs = timeoutMs;
|
|
45
50
|
this.sleep = sleep;
|
|
46
51
|
this.userAgent = userAgent;
|
|
52
|
+
this.onEvent = onEvent;
|
|
53
|
+
this.compactAfterInputTokens = compactAfterInputTokens;
|
|
54
|
+
this.compactMaxCharacters = compactMaxCharacters;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
async request(body) {
|
|
@@ -69,6 +77,7 @@ export class AzureResponsesHarness {
|
|
|
69
77
|
if (!TRANSIENT_STATUS.has(response.status) || attempt === this.retries) {
|
|
70
78
|
throw new Error(`Azure Responses HTTP ${response.status}: ${payload.error?.code ?? "request_failed"}`);
|
|
71
79
|
}
|
|
80
|
+
this.onEvent({ type: "model.retry", model: this.model, attempt: attempt + 1, status: response.status });
|
|
72
81
|
} catch (error) {
|
|
73
82
|
const transientNetworkError = error.name === "AbortError" || error instanceof TypeError;
|
|
74
83
|
if (!transientNetworkError || attempt === this.retries) throw error;
|
|
@@ -81,26 +90,34 @@ export class AzureResponsesHarness {
|
|
|
81
90
|
throw new Error("Azure Responses retry loop exhausted");
|
|
82
91
|
}
|
|
83
92
|
|
|
84
|
-
async run(prompt) {
|
|
93
|
+
async run(prompt, { immutableContext = prompt } = {}) {
|
|
85
94
|
let input = prompt;
|
|
86
95
|
let previousResponseId;
|
|
87
96
|
const definitions = toolDefinitions(this.tools);
|
|
88
97
|
const usage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
|
|
98
|
+
const trace = [];
|
|
89
99
|
for (let step = 0; step < this.maxSteps; step += 1) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
100
|
+
this.onEvent({ type: "model.request.started", model: this.model, step: step + 1 });
|
|
101
|
+
let response;
|
|
102
|
+
try {
|
|
103
|
+
response = await this.request({
|
|
104
|
+
model: this.model,
|
|
105
|
+
input,
|
|
106
|
+
...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
|
|
107
|
+
tools: definitions,
|
|
108
|
+
max_output_tokens: this.maxOutputTokens,
|
|
109
|
+
});
|
|
110
|
+
} catch (error) {
|
|
111
|
+
this.onEvent({ type: "model.request.failed", model: this.model, step: step + 1, error: String(error.message ?? error).slice(0, 500) });
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
this.onEvent({ type: "model.request.completed", model: this.model, step: step + 1, usage: response.usage });
|
|
97
115
|
usage.inputTokens += response.usage?.input_tokens ?? 0;
|
|
98
116
|
usage.cachedInputTokens += response.usage?.input_tokens_details?.cached_tokens ?? 0;
|
|
99
117
|
usage.outputTokens += response.usage?.output_tokens ?? 0;
|
|
100
118
|
const calls = (response.output ?? []).filter((item) => item.type === "function_call");
|
|
101
119
|
if (calls.length === 0) return { text: outputText(response), responseId: response.id, steps: step + 1, usage };
|
|
102
|
-
const
|
|
103
|
-
for (const call of calls) {
|
|
120
|
+
const prepared = calls.map((call) => {
|
|
104
121
|
const tool = this.tools[call.name];
|
|
105
122
|
if (!tool) throw new Error(`Tool not allowed: ${call.name}`);
|
|
106
123
|
let argumentsValue;
|
|
@@ -109,20 +126,36 @@ export class AzureResponsesHarness {
|
|
|
109
126
|
} catch {
|
|
110
127
|
throw new Error(`Invalid arguments for tool: ${call.name}`);
|
|
111
128
|
}
|
|
129
|
+
return { call, tool, argumentsValue };
|
|
130
|
+
});
|
|
131
|
+
const executeCall = async ({ call, tool, argumentsValue }) => {
|
|
112
132
|
let value;
|
|
113
133
|
try {
|
|
134
|
+
this.onEvent({ type: "tool.started", tool: call.name, step: step + 1 });
|
|
114
135
|
value = await tool.execute(argumentsValue);
|
|
136
|
+
this.onEvent({ type: "tool.completed", tool: call.name, step: step + 1 });
|
|
115
137
|
} catch (error) {
|
|
138
|
+
this.onEvent({ type: "tool.failed", tool: call.name, step: step + 1, error: String(error.message ?? error).slice(0, 500) });
|
|
116
139
|
value = `ERROR: ${String(error.message ?? error).slice(0, 4000)}`;
|
|
117
140
|
}
|
|
118
|
-
|
|
141
|
+
trace.push({ tool: call.name, output: typeof value === "string" ? value : JSON.stringify(value), status: String(value).startsWith("ERROR:") ? "error" : "success" });
|
|
142
|
+
return {
|
|
119
143
|
type: "function_call_output",
|
|
120
144
|
call_id: call.call_id,
|
|
121
145
|
output: typeof value === "string" ? value : JSON.stringify(value),
|
|
122
|
-
}
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
const outputs = prepared.every(({ tool }) => tool.parallelSafe === true)
|
|
149
|
+
? await Promise.all(prepared.map(executeCall))
|
|
150
|
+
: await prepared.reduce(async (pending, item) => [...await pending, await executeCall(item)], Promise.resolve([]));
|
|
151
|
+
if (this.compactAfterInputTokens > 0 && (response.usage?.input_tokens ?? 0) >= this.compactAfterInputTokens) {
|
|
152
|
+
previousResponseId = undefined;
|
|
153
|
+
input = compactCheckpoint(prompt, trace, { maxCharacters: this.compactMaxCharacters, immutableContext });
|
|
154
|
+
this.onEvent({ type: "context.compacted", model: this.model, step: step + 1, inputTokens: response.usage?.input_tokens ?? 0 });
|
|
155
|
+
} else {
|
|
156
|
+
previousResponseId = response.id;
|
|
157
|
+
input = outputs;
|
|
123
158
|
}
|
|
124
|
-
previousResponseId = response.id;
|
|
125
|
-
input = outputs;
|
|
126
159
|
}
|
|
127
160
|
throw new Error(`Agent exceeded step limit of ${this.maxSteps}`);
|
|
128
161
|
}
|
package/src/bedrock-harness.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime";
|
|
2
|
+
import { compactCheckpoint } from "./context-compaction.js";
|
|
2
3
|
|
|
3
4
|
function definitions(tools) {
|
|
4
5
|
return Object.entries(tools).map(([name, tool]) => ({
|
|
@@ -11,24 +12,36 @@ function definitions(tools) {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export class BedrockHarness {
|
|
14
|
-
constructor({ client, region = process.env.AWS_REGION ?? "us-east-1", model, tools, maxSteps = 40, maxOutputTokens = 4096 }) {
|
|
15
|
-
this.client = client ?? new BedrockRuntimeClient({ region, customUserAgent: `factory-ai/${process.env.FACTORY_VERSION ?? "dev"}` });
|
|
15
|
+
constructor({ client, region = process.env.AWS_REGION ?? "us-east-1", credentials, model, tools, maxSteps = 40, maxOutputTokens = 4096, onEvent = () => {}, compactAfterInputTokens = 80_000, compactMaxCharacters = 24_000 }) {
|
|
16
|
+
this.client = client ?? new BedrockRuntimeClient({ region, credentials, customUserAgent: `factory-ai/${process.env.FACTORY_VERSION ?? "dev"}` });
|
|
16
17
|
this.model = model;
|
|
17
18
|
this.tools = tools;
|
|
18
19
|
this.maxSteps = maxSteps;
|
|
19
20
|
this.maxOutputTokens = maxOutputTokens;
|
|
21
|
+
this.onEvent = onEvent;
|
|
22
|
+
this.compactAfterInputTokens = compactAfterInputTokens;
|
|
23
|
+
this.compactMaxCharacters = compactMaxCharacters;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
async run(prompt) {
|
|
26
|
+
async run(prompt, { immutableContext = prompt } = {}) {
|
|
23
27
|
const messages = [{ role: "user", content: [{ text: prompt }] }];
|
|
24
28
|
const usage = { inputTokens: 0, outputTokens: 0 };
|
|
29
|
+
const trace = [];
|
|
25
30
|
for (let step = 0; step < this.maxSteps; step += 1) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
this.onEvent({ type: "model.request.started", model: this.model, step: step + 1 });
|
|
32
|
+
let response;
|
|
33
|
+
try {
|
|
34
|
+
response = await this.client.send(new ConverseCommand({
|
|
35
|
+
modelId: this.model,
|
|
36
|
+
messages,
|
|
37
|
+
inferenceConfig: { maxTokens: this.maxOutputTokens },
|
|
38
|
+
...(Object.keys(this.tools).length ? { toolConfig: { tools: definitions(this.tools) } } : {}),
|
|
39
|
+
}));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
this.onEvent({ type: "model.request.failed", model: this.model, step: step + 1, error: String(error.message ?? error).slice(0, 500) });
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
this.onEvent({ type: "model.request.completed", model: this.model, step: step + 1, usage: response.usage });
|
|
32
45
|
usage.inputTokens += response.usage?.inputTokens ?? 0;
|
|
33
46
|
usage.outputTokens += response.usage?.outputTokens ?? 0;
|
|
34
47
|
const message = response.output?.message;
|
|
@@ -38,16 +51,34 @@ export class BedrockHarness {
|
|
|
38
51
|
return { text, steps: step + 1, usage };
|
|
39
52
|
}
|
|
40
53
|
messages.push(message);
|
|
41
|
-
const
|
|
42
|
-
for (const call of calls) {
|
|
54
|
+
const prepared = calls.map((call) => {
|
|
43
55
|
const tool = this.tools[call.name];
|
|
44
56
|
if (!tool) throw new Error(`Tool not allowed: ${call.name}`);
|
|
57
|
+
return { call, tool };
|
|
58
|
+
});
|
|
59
|
+
const executeCall = async ({ call, tool }) => {
|
|
45
60
|
let value;
|
|
46
61
|
let status = "success";
|
|
47
|
-
try {
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
try {
|
|
63
|
+
this.onEvent({ type: "tool.started", tool: call.name, step: step + 1 });
|
|
64
|
+
value = await tool.execute(call.input ?? {});
|
|
65
|
+
this.onEvent({ type: "tool.completed", tool: call.name, step: step + 1 });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
value = `ERROR: ${String(error.message ?? error).slice(0, 4000)}`;
|
|
68
|
+
status = "error";
|
|
69
|
+
this.onEvent({ type: "tool.failed", tool: call.name, step: step + 1, error: String(error.message ?? error).slice(0, 500) });
|
|
70
|
+
}
|
|
71
|
+
trace.push({ tool: call.name, output: typeof value === "string" ? value : JSON.stringify(value), status });
|
|
72
|
+
return { toolResult: { toolUseId: call.toolUseId, content: [{ text: typeof value === "string" ? value : JSON.stringify(value) }], status } };
|
|
73
|
+
};
|
|
74
|
+
const results = prepared.every(({ tool }) => tool.parallelSafe === true)
|
|
75
|
+
? await Promise.all(prepared.map(executeCall))
|
|
76
|
+
: await prepared.reduce(async (pending, item) => [...await pending, await executeCall(item)], Promise.resolve([]));
|
|
50
77
|
messages.push({ role: "user", content: results });
|
|
78
|
+
if (this.compactAfterInputTokens > 0 && (response.usage?.inputTokens ?? 0) >= this.compactAfterInputTokens) {
|
|
79
|
+
messages.splice(0, messages.length, { role: "user", content: [{ text: compactCheckpoint(prompt, trace, { maxCharacters: this.compactMaxCharacters, immutableContext }) }] });
|
|
80
|
+
this.onEvent({ type: "context.compacted", model: this.model, step: step + 1, inputTokens: response.usage?.inputTokens ?? 0 });
|
|
81
|
+
}
|
|
51
82
|
}
|
|
52
83
|
throw new Error(`Agent exceeded step limit of ${this.maxSteps}`);
|
|
53
84
|
}
|
package/src/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { validateHooks } from "./hooks.js";
|
|
3
4
|
|
|
4
5
|
const environmentSchema = z.object({
|
|
5
6
|
SERVICE_BUS_NAMESPACE: z.string().min(3),
|
|
@@ -19,6 +20,9 @@ const environmentSchema = z.object({
|
|
|
19
20
|
MAX_CONCURRENCY: z.coerce.number().int().min(1).max(3).default(3),
|
|
20
21
|
TASK_TIMEOUT_MS: z.coerce.number().int().min(60_000).max(3_600_000).default(1_800_000),
|
|
21
22
|
MAX_DELIVERY_COUNT: z.coerce.number().int().min(2).max(20).default(8),
|
|
23
|
+
FACTORY_WATCHDOG_STALE_SECONDS: z.coerce.number().int().min(300).max(7200).default(900),
|
|
24
|
+
FACTORY_REPO_MAP_MAX_CHARACTERS: z.coerce.number().int().min(2000).max(20_000).default(8000),
|
|
25
|
+
FACTORY_HOOKS_JSON: z.string().default("[]"),
|
|
22
26
|
AZURE_PRIMARY_API_KEY_SECRET: z.string().min(1).default("azure-primary-api-key"),
|
|
23
27
|
AZURE_PRIMARY_BASE_URL_SECRET: z.string().min(1).default("azure-primary-base-url"),
|
|
24
28
|
AZURE_SMALL_API_KEY_SECRET: z.string().min(1).default("azure-small-api-key"),
|
|
@@ -34,6 +38,9 @@ const environmentSchema = z.object({
|
|
|
34
38
|
export function loadConfig(environment = process.env) {
|
|
35
39
|
const env = environmentSchema.parse(environment);
|
|
36
40
|
if (env.CONTROL_QUEUE === env.AGENT_QUEUE) throw new Error("Control and agent queues must be different");
|
|
41
|
+
if (new Set([env.CONTROL_QUEUE, env.AGENT_QUEUE, env.RELEASE_QUEUE]).size !== 3) throw new Error("Control, agent, and release queues must be different");
|
|
42
|
+
let hooks;
|
|
43
|
+
try { hooks = validateHooks(JSON.parse(env.FACTORY_HOOKS_JSON)); } catch (error) { throw new Error(`Invalid FACTORY_HOOKS_JSON: ${error.message}`); }
|
|
37
44
|
return {
|
|
38
45
|
serviceBusFqdn: env.SERVICE_BUS_NAMESPACE.includes(".")
|
|
39
46
|
? env.SERVICE_BUS_NAMESPACE
|
|
@@ -67,5 +74,7 @@ export function loadConfig(environment = process.env) {
|
|
|
67
74
|
concurrency: env.MAX_CONCURRENCY,
|
|
68
75
|
timeoutMs: env.TASK_TIMEOUT_MS,
|
|
69
76
|
maxDeliveryCount: env.MAX_DELIVERY_COUNT,
|
|
77
|
+
repoMapMaxCharacters: env.FACTORY_REPO_MAP_MAX_CHARACTERS,
|
|
78
|
+
hooks,
|
|
70
79
|
};
|
|
71
80
|
}
|