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.
Files changed (61) hide show
  1. package/CHANGELOG.md +16 -4
  2. package/Dockerfile.worker +1 -1
  3. package/README.md +8 -2
  4. package/ROADMAP.md +0 -1
  5. package/bin/factory +118 -1
  6. package/bootstrap/agent-factory-worker.service +2 -2
  7. package/bootstrap/auto-update.sh +20 -12
  8. package/bootstrap/deploy-runtime.sh +5 -3
  9. package/bootstrap/factory-ai-container-firewall.service +14 -0
  10. package/bootstrap/factory-ai-snapshot.service +1 -0
  11. package/bootstrap/factory-ai-watchdog.service +21 -0
  12. package/bootstrap/factory-ai-watchdog.timer +11 -0
  13. package/bootstrap/setup.sh +20 -9
  14. package/cmd/factory-ui/main.go +455 -0
  15. package/go.mod +39 -0
  16. package/go.sum +82 -0
  17. package/package.json +7 -3
  18. package/scripts/test.js +7 -0
  19. package/src/acp-adapter.js +26 -0
  20. package/src/acp-cli.js +16 -0
  21. package/src/activity.js +96 -0
  22. package/src/agent-executor.js +39 -5
  23. package/src/agent-runner.js +30 -10
  24. package/src/approval-policy.js +12 -0
  25. package/src/azure-harness.js +47 -14
  26. package/src/bedrock-harness.js +45 -14
  27. package/src/config.js +9 -0
  28. package/src/container-runner.js +65 -12
  29. package/src/context-compaction.js +16 -0
  30. package/src/control-plane.js +77 -16
  31. package/src/control-service.js +6 -1
  32. package/src/dashboard.js +25 -6
  33. package/src/extension-cli.js +11 -0
  34. package/src/extension-manifest.js +76 -0
  35. package/src/hooks.js +62 -0
  36. package/src/instructions.js +45 -0
  37. package/src/mcp-tools.js +19 -3
  38. package/src/objective-status.js +16 -0
  39. package/src/operator-logs.js +24 -0
  40. package/src/operator.js +31 -9
  41. package/src/process.js +3 -3
  42. package/src/release-gate.js +1 -0
  43. package/src/release-service.js +4 -1
  44. package/src/repo-map.js +128 -0
  45. package/src/reporter.js +11 -0
  46. package/src/retriever.js +18 -18
  47. package/src/routing.js +11 -1
  48. package/src/scanner-suite.js +4 -3
  49. package/src/snapshot.js +16 -0
  50. package/src/task-entry.js +28 -6
  51. package/src/task-graph.js +0 -5
  52. package/src/telegram-service.js +7 -1
  53. package/src/telegram.js +5 -1
  54. package/src/telemetry.js +173 -0
  55. package/src/tui.js +27 -8
  56. package/src/validation.js +41 -9
  57. package/src/watchdog.js +62 -0
  58. package/src/worker.js +32 -3
  59. package/src/workspace-tools.js +13 -3
  60. package/src/workspace.js +22 -2
  61. package/templates/AGENTS.md +15 -0
@@ -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
+ }
@@ -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 = [...(message.context ?? []), ...(semanticContext ? [{ type: "local-semantic-retrieval", content: semanticContext }] : [])];
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 checkpoint = await this.workspaces.checkpoint(directory, message.objective, message.task);
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
  }
@@ -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 [provider, model] = route.split("/");
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 response = await this.harness(task, directory, mcp.tools).run(
91
- await this.promptForTask(objective, task, prompt, capabilities),
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
+ }
@@ -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
- const response = await this.request({
91
- model: this.model,
92
- input,
93
- ...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
94
- tools: definitions,
95
- max_output_tokens: this.maxOutputTokens,
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 outputs = [];
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
- outputs.push({
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
  }
@@ -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
- const response = await this.client.send(new ConverseCommand({
27
- modelId: this.model,
28
- messages,
29
- inferenceConfig: { maxTokens: this.maxOutputTokens },
30
- ...(Object.keys(this.tools).length ? { toolConfig: { tools: definitions(this.tools) } } : {}),
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 results = [];
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 { value = await tool.execute(call.input ?? {}); } catch (error) { value = `ERROR: ${String(error.message ?? error).slice(0, 4000)}`; status = "error"; }
48
- results.push({ toolResult: { toolUseId: call.toolUseId, content: [{ text: typeof value === "string" ? value : JSON.stringify(value) }], status } });
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
  }