factory-ai 1.0.2 → 1.2.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/ARCHITECTURE.md CHANGED
@@ -4,6 +4,6 @@ The CEO submits one objective. A deterministic control service persists state an
4
4
 
5
5
  The control service has no model, shell, Git, workspace, Docker, Key Vault secret-loading, or release implementation. Agent containers have bounded CPU, memory, PIDs, steps, output, runtime, filesystem mounts, commands, skills, and MCP tools. GitHub credentials remain on trusted host services.
6
6
 
7
- Durability comes from Service Bus peek-lock/redelivery, systemd supervision, a retained Premium SSD, atomic state writes, Git checkpoints, and GitHub pull requests. Benchmarked GPT-5.4 nano handles scouting, GPT-5.4 handles independent testing, Kimi K2.7-Code handles implementation, and GPT-5.6 handles planning, debugging, review, security, and release analysis.
7
+ Durability comes from Service Bus peek-lock/redelivery, systemd supervision, a retained Premium SSD, atomic state writes, Git checkpoints, and GitHub pull requests. Benchmarked GPT-5.4 nano handles scouting, GPT-5.4 handles independent testing, Kimi K2.7-Code handles simple implementation, GPT-5.5 handles complex implementation, and GPT-5.6 handles planning, debugging, review, security, and release analysis.
8
8
 
9
9
  Trust boundaries: CEO input and repository content are untrusted; capability definitions are pinned and allowlisted; agent containers cannot publish; the release bot cannot bypass approval gates; Azure credentials are loaded from Key Vault into process memory.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,22 @@ All notable changes follow semantic versioning and the Keep a Changelog structur
11
11
  - Azure and Bedrock provider wizard.
12
12
  - Durable evaluation, analytics, scaling, policy, extension, and recovery roadmap.
13
13
 
14
+ ## [1.2.0] - 2026-07-13
15
+
16
+ ### Added
17
+
18
+ - Telegram natural-language objectives, default repositories, objective lookup, and automatic progress/completion notifications.
19
+ - Six-hour verified stable updates with CI/security gates, durable version records, major-version blocking, and rollback.
20
+
21
+ ## [1.1.0] - 2026-07-13
22
+
23
+ ### Added
24
+
25
+ - GPT-5.5 as the benchmarked default implementation model, with Kimi retained for explicitly simple tasks.
26
+ - Per-role step/output budgets and per-model input/cache/output token telemetry.
27
+ - Cache-friendly prompt ordering and compact deterministic memory.
28
+ - Bounded line-range reads, listings, command output, MCP output, and scanner evidence.
29
+
14
30
  ## [1.0.2] - 2026-07-13
15
31
 
16
32
  ### Fixed
package/README.md CHANGED
@@ -57,7 +57,7 @@ factory ui
57
57
 
58
58
  [running] Add authenticated health checks
59
59
  succeeded scout GPT-5.4 nano · inspect conventions
60
- running builder GPT-5.6 · implement contract
60
+ running builder GPT-5.5 · implement contract
61
61
  blocked tester GPT-5.4 · verify behavior
62
62
  blocked reviewer GPT-5.6 · review correctness
63
63
  blocked security GPT-5.6 · assess boundaries
@@ -115,12 +115,25 @@ Defaults are evidence-based and role-specific:
115
115
  | --- | --- | --- |
116
116
  | Scout | GPT-5.4 nano | Low-cost search and repository inspection |
117
117
  | Simple builder task | Kimi K2.7-Code | Economy coding path, independently reviewed |
118
- | Complex/unspecified builder task | GPT-5.6 | Fail-safe implementation default |
118
+ | Complex/unspecified builder task | GPT-5.5 | Faster benchmarked implementation default |
119
119
  | Tester | GPT-5.4 | Independent behavioral verification |
120
120
  | Planner, debugger, reviewer, security, release | GPT-5.6 | Higher-judgment work |
121
121
 
122
122
  Any role can be overridden with an Azure deployment or `bedrock/MODEL_ID`. Bedrock uses the Converse tool API behind the same sandbox and approval gates.
123
123
 
124
+ ## Token and Cost Efficiency
125
+
126
+ Factory AI minimizes tokens before relying on cheaper models:
127
+
128
+ - GPT-5.4 nano scouts, Kimi handles explicitly simple coding, GPT-5.5 handles complex coding, and GPT-5.6 is reserved for high-judgment roles.
129
+ - Stable guardrail/skill prompt prefixes improve provider prompt-cache reuse.
130
+ - Every role has explicit step and output-token budgets.
131
+ - File reads are line-ranged and bounded; listings, commands, MCP output, memory, and scanner evidence are truncated with continuation hints.
132
+ - Read-only roles do not receive write-tool schemas.
133
+ - Planner memory is compact, repository-scoped, and limited to recent verified events.
134
+ - Dashboard and TUI track input, cached-input, and output tokens by model.
135
+ - The planner is instructed to produce the smallest valid DAG, avoiding duplicate agents.
136
+
124
137
  ## Reliability
125
138
 
126
139
  - Azure Service Bus peek-lock delivery, duplicate detection, retries, and dead letters
@@ -188,7 +201,20 @@ Only explicitly allowlisted chat IDs are accepted. Supported commands:
188
201
  /help
189
202
  ```
190
203
 
191
- Telegram cannot run shell commands, read secrets, modify release policy, or bypass review gates. Durable update offsets prevent duplicate objectives after restarts.
204
+ Set a default repository with `/repo OWNER/REPO`, then send plain-text instructions without a command. `/recent` lists recent objectives and `/objective ID` shows task-level detail. Factory AI automatically pushes deduplicated status, active-agent, completion, PR, failure, and blocker updates to the originating chat.
205
+
206
+ Telegram cannot run shell commands, read secrets, modify release policy, or bypass review gates. Durable update offsets, repository preferences, and objective subscriptions survive restarts.
207
+
208
+ ## Verified Automatic Updates
209
+
210
+ The VM checks npm stable releases every six hours with a randomized delay. Updates are accepted only when:
211
+
212
+ - The release remains within the installed major version.
213
+ - npm `gitHead` resolves to the exact GitHub commit.
214
+ - The commit has successful CI.
215
+ - A fresh isolated clone passes install, syntax, lint, tests, dependency audit, Bicep, shell validation, and Gitleaks.
216
+
217
+ The updater records the installed version on retained storage and restores the previous commit if deployment fails. Major upgrades always require explicit operator action.
192
218
 
193
219
  ## Security
194
220
 
package/RUNBOOK.md CHANGED
@@ -31,6 +31,8 @@ factory setup
31
31
 
32
32
  Override defaults with `FACTORY_RESOURCE_GROUP`, `FACTORY_VM`, and `FACTORY_SERVICE_BUS`.
33
33
 
34
+ Verified stable updates run every six hours through `factory-ai-update.timer`. Inspect with `systemctl status factory-ai-update.timer` and `journalctl -u factory-ai-update.service`. Automatic major-version upgrades are intentionally blocked.
35
+
34
36
  ## Recovery
35
37
 
36
38
  If a worker dies, systemd restarts it and Service Bus redelivers after lock expiry. Do not manually duplicate the task. Check `dashboard`, then `logs`, then queue dead-letter counts. Preserve objective state before purging dead letters.
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ exec 9>/var/lock/factory-ai-update.lock
5
+ flock -n 9 || { printf 'Factory AI update already running.\n'; exit 0; }
6
+
7
+ app=/opt/agent-factory/app
8
+ state=/opt/agent-factory/state
9
+ status=$(FACTORY_PACKAGE_FILE="$app/package.json" node "$app/src/updater.js")
10
+ available=$(jq -r .updateAvailable <<<"$status")
11
+ [[ $available == true ]] || { printf 'Factory AI is current: %s\n' "$(jq -r .current <<<"$status")"; exit 0; }
12
+
13
+ version=$(jq -r .latest <<<"$status")
14
+ commit=$(jq -r .gitHead <<<"$status")
15
+ [[ $commit =~ ^[0-9a-f]{40}$ ]] || { printf 'npm release does not contain a valid gitHead.\n' >&2; exit 1; }
16
+
17
+ set -a
18
+ source /etc/agent-factory.env
19
+ set +a
20
+ github_token=$(az keyvault secret show --vault-name "$KEY_VAULT_NAME" --name github-token --query value --output tsv)
21
+ checks=$(GH_TOKEN="$github_token" gh api "repos/itsvedantkumar/factory-ai/commits/$commit/check-runs" --jq '[.check_runs[] | select(.name == "verify") | .conclusion] | any(. == "success")')
22
+ [[ $checks == true ]] || { printf 'Candidate commit lacks successful CI verification.\n' >&2; exit 1; }
23
+
24
+ temporary=$(mktemp -d /var/tmp/factory-ai-update.XXXXXX)
25
+ trap 'rm -rf "$temporary"; unset github_token' EXIT
26
+ previous_commit=$(jq -r '.commit // empty' "$state/runtime-version.json" 2>/dev/null || true)
27
+ cp "$app/bootstrap/deploy-runtime.sh" "$temporary/rollback-deploy.sh"
28
+ GH_TOKEN="$github_token" gh repo clone itsvedantkumar/factory-ai "$temporary/source" -- --no-checkout
29
+ git -C "$temporary/source" checkout --detach "$commit"
30
+ test "$(git -C "$temporary/source" rev-parse HEAD)" = "$commit"
31
+
32
+ npm ci --prefix "$temporary/source"
33
+ npm run check --prefix "$temporary/source"
34
+ npm run lint --prefix "$temporary/source"
35
+ npm test --prefix "$temporary/source"
36
+ npm audit --prefix "$temporary/source" --audit-level=high
37
+ az bicep build --file "$temporary/source/infra/main.bicep" --stdout >/dev/null
38
+ bash -n "$temporary/source/bootstrap/setup.sh" "$temporary/source/bootstrap/deploy-runtime.sh" "$temporary/source/bin/factory"
39
+ docker run --rm --read-only --volume "$temporary/source:/workspace:ro" \
40
+ zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \
41
+ detect --source /workspace --redact --no-banner --exit-code 1
42
+
43
+ printf 'Deploying Factory AI %s (%s)\n' "$version" "$commit"
44
+ if ! bash "$temporary/source/bootstrap/deploy-runtime.sh" \
45
+ KEY_VAULT_NAME "$KEY_VAULT_NAME" \
46
+ SERVICE_BUS_NAMESPACE "$SERVICE_BUS_NAMESPACE" \
47
+ SERVICE_BUS_QUEUE code-tasks \
48
+ SOURCE_REPOSITORY itsvedantkumar/factory-ai \
49
+ SOURCE_REF "$commit"; then
50
+ printf 'Candidate deployment failed. Restoring previous runtime.\n' >&2
51
+ if [[ $previous_commit =~ ^[0-9a-f]{40}$ ]]; then
52
+ bash "$temporary/rollback-deploy.sh" \
53
+ KEY_VAULT_NAME "$KEY_VAULT_NAME" \
54
+ SERVICE_BUS_NAMESPACE "$SERVICE_BUS_NAMESPACE" \
55
+ SERVICE_BUS_QUEUE code-tasks \
56
+ SOURCE_REPOSITORY itsvedantkumar/factory-ai \
57
+ SOURCE_REF "$previous_commit"
58
+ fi
59
+ exit 1
60
+ fi
61
+ printf 'Factory AI updated to %s.\n' "$version"
@@ -72,3 +72,11 @@ FACTORY_MODEL_REVIEWER="${FACTORY_MODEL_REVIEWER:-}" \
72
72
  FACTORY_MODEL_SECURITY="${FACTORY_MODEL_SECURITY:-}" \
73
73
  FACTORY_MODEL_RELEASE="${FACTORY_MODEL_RELEASE:-}" \
74
74
  bash /opt/agent-factory/app/bootstrap/setup.sh
75
+ install -d -o factory -g factory -m 0750 /opt/agent-factory/state
76
+ version=$(node -p 'require("/opt/agent-factory/app/package.json").version')
77
+ jq -n --arg version "$version" --arg commit "$SOURCE_REF" --arg repository "$SOURCE_REPOSITORY" \
78
+ '{version:$version,commit:$commit,repository:$repository,installedAt:(now|todate)}' \
79
+ > /opt/agent-factory/state/runtime-version.json.tmp
80
+ chown factory:factory /opt/agent-factory/state/runtime-version.json.tmp
81
+ chmod 0640 /opt/agent-factory/state/runtime-version.json.tmp
82
+ mv /opt/agent-factory/state/runtime-version.json.tmp /opt/agent-factory/state/runtime-version.json
@@ -0,0 +1,17 @@
1
+ [Unit]
2
+ Description=Factory AI verified stable update
3
+ After=network-online.target docker.service
4
+ Wants=network-online.target
5
+ Requires=docker.service
6
+
7
+ [Service]
8
+ Type=oneshot
9
+ User=root
10
+ Group=root
11
+ ExecStart=/opt/agent-factory/app/bootstrap/auto-update.sh
12
+ Nice=10
13
+ IOSchedulingClass=best-effort
14
+ IOSchedulingPriority=7
15
+ PrivateTmp=true
16
+ ProtectHome=true
17
+ UMask=0027
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=Check for verified Factory AI updates every six hours
3
+
4
+ [Timer]
5
+ OnCalendar=*-*-* 00/6:00:00
6
+ RandomizedDelaySec=30m
7
+ Persistent=true
8
+
9
+ [Install]
10
+ WantedBy=timers.target
@@ -114,11 +114,15 @@ install -m 0644 "$APP_DIR/bootstrap/agent-factory-release.service" /etc/systemd/
114
114
  install -m 0644 "$APP_DIR/bootstrap/agent-factory-reporter.service" /etc/systemd/system/agent-factory-reporter.service
115
115
  install -m 0644 "$APP_DIR/bootstrap/agent-factory-reporter.timer" /etc/systemd/system/agent-factory-reporter.timer
116
116
  install -m 0644 "$APP_DIR/bootstrap/agent-factory-telegram.service" /etc/systemd/system/agent-factory-telegram.service
117
+ install -m 0755 "$APP_DIR/bootstrap/auto-update.sh" /opt/agent-factory/app/bootstrap/auto-update.sh
118
+ install -m 0644 "$APP_DIR/bootstrap/factory-ai-update.service" /etc/systemd/system/factory-ai-update.service
119
+ install -m 0644 "$APP_DIR/bootstrap/factory-ai-update.timer" /etc/systemd/system/factory-ai-update.timer
117
120
  systemctl daemon-reload
118
121
  systemctl enable --now agent-factory-worker.service
119
122
  systemctl enable --now agent-factory-control.service
120
123
  systemctl enable --now agent-factory-release.service
121
124
  systemctl enable --now agent-factory-reporter.timer
122
125
  systemctl enable --now agent-factory-telegram.service
126
+ systemctl enable --now factory-ai-update.timer
123
127
  systemctl restart agent-factory-control.service agent-factory-worker.service agent-factory-release.service
124
128
  echo "Agent factory worker installed"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "factory-ai",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "Deploy a private autonomous coding-agent factory on Azure: isolated builders, testers, security reviewers, durable orchestration, multi-model routing, memory, cost controls, and gated GitHub pull requests.",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -24,6 +24,15 @@ function endpointForRoute(route, role, environment) {
24
24
  return { baseUrl, apiKey, model };
25
25
  }
26
26
 
27
+ function budgetFor(task) {
28
+ if (task.role === "scout") return { maxSteps: 14, maxOutputTokens: 1200 };
29
+ if (task.role === "builder" && task.complexity === "simple") return { maxSteps: 20, maxOutputTokens: 2400 };
30
+ if (task.role === "builder") return { maxSteps: 32, maxOutputTokens: 3200 };
31
+ if (task.role === "debugger") return { maxSteps: 36, maxOutputTokens: 3200 };
32
+ if (["tester", "reviewer", "security"].includes(task.role)) return { maxSteps: 22, maxOutputTokens: 1800 };
33
+ return { maxSteps: 22, maxOutputTokens: 2200 };
34
+ }
35
+
27
36
  export class AzureAgentRunner {
28
37
  constructor(config, registry, {
29
38
  environment = process.env,
@@ -42,40 +51,56 @@ export class AzureAgentRunner {
42
51
  `ALLOWLISTED SKILL ${item.name}@${item.version}:\n${await readFile(item.path, "utf8")}`
43
52
  )));
44
53
  return [
45
- `You are the isolated ${task.role} subagent for CEO objective: ${objective.objective}`,
46
- task.instructions,
54
+ `You are a Factory AI isolated ${task.role} subagent.`,
47
55
  "Work only in the assigned repository. Never inspect credentials, push Git refs, deploy, or install global tools.",
48
56
  "Use tools for evidence. Make the smallest correct change and verify every completion claim.",
49
- prompt,
50
- ...skills,
51
57
  'Return only JSON: {"summary":"concise outcome","checks":["command/result"],"risks":["remaining risk"],"approval":"approved|changes_requested|not_applicable"}.',
58
+ ...skills,
59
+ `CEO objective: ${objective.objective}`,
60
+ task.instructions,
61
+ prompt,
52
62
  ].join("\n\n");
53
63
  }
54
64
 
55
65
  harness(task, directory, additionalTools = {}) {
56
66
  const role = task.role;
57
67
  const route = modelForTask(task, this.environment);
58
- const tools = { ...createWorkspaceTools(directory), ...additionalTools };
59
- const maxSteps = role === "scout" ? 20 : 40;
68
+ const workspaceTools = createWorkspaceTools(directory);
69
+ if (!["builder", "debugger"].includes(role)) delete workspaceTools.write_file;
70
+ const tools = { ...workspaceTools, ...additionalTools };
71
+ const budget = budgetFor(task);
60
72
  if (route.startsWith("bedrock/")) {
61
73
  return this.createBedrockHarness({
62
74
  region: this.environment.AWS_REGION ?? "us-east-1",
63
75
  model: route.slice("bedrock/".length),
64
76
  tools,
65
- maxSteps,
77
+ ...budget,
66
78
  });
67
79
  }
68
- return this.createHarness({ ...endpointForRoute(route, role, this.environment), tools, timeoutMs: this.config.timeoutMs, maxSteps });
80
+ return this.createHarness({ ...endpointForRoute(route, role, this.environment), tools, timeoutMs: this.config.timeoutMs, ...budget });
69
81
  }
70
82
 
71
83
  async invoke({ objective, task, directory, prompt }) {
72
84
  const capabilities = selectCapabilities(this.registry, task.role, task.capabilities);
73
85
  const mcp = await connectMcpTools(capabilities);
86
+ const started = Date.now();
74
87
  try {
75
88
  const response = await this.harness(task, directory, mcp.tools).run(
76
89
  await this.promptForTask(objective, task, prompt, capabilities),
77
90
  );
78
- return parseJson(response.text);
91
+ return {
92
+ ...parseJson(response.text),
93
+ telemetry: {
94
+ model: modelForTask(task, this.environment),
95
+ steps: response.steps ?? 0,
96
+ durationMs: Date.now() - started,
97
+ usage: {
98
+ inputTokens: response.usage?.inputTokens ?? 0,
99
+ cachedInputTokens: response.usage?.cachedInputTokens ?? 0,
100
+ outputTokens: response.usage?.outputTokens ?? 0,
101
+ },
102
+ },
103
+ };
79
104
  } finally {
80
105
  await mcp.close();
81
106
  }
@@ -90,15 +115,16 @@ export class AzureAgentRunner {
90
115
  ...Object.entries(this.registry.skills ?? {}),
91
116
  ...Object.entries(this.registry.mcp ?? {}),
92
117
  ].map(([name, item]) => [name, { version: item.version, roles: item.roles }]));
93
- const prompt = `You are a planner subagent. Decompose the objective into a small executable DAG.
94
- Objective: ${objective.objective}
95
- Verified prior project context: ${JSON.stringify(projectContext)}
118
+ const prompt = `You are a Factory AI planner subagent. Decompose objectives into the smallest executable DAG.
96
119
  Allowed roles: scout, builder, tester, debugger, reviewer, security, release.
97
120
  Allowed capabilities: ${JSON.stringify(registrySummary)}
98
121
  Include tester, reviewer, and security ancestors of exactly one terminal release task.
99
122
  Return only JSON: {"executiveIntent":"...","tasks":[{"id":"...","role":"...","title":"...","instructions":"...","dependsOn":[],"capabilities":[],"complexity":"simple|complex"}]}
100
123
 
101
- ${plannerSkills.join("\n\n")}`;
124
+ ${plannerSkills.join("\n\n")}
125
+
126
+ Objective: ${objective.objective}
127
+ Verified prior project context: ${JSON.stringify(projectContext).slice(0, 12000)}`;
102
128
  const mcp = await connectMcpTools(plannerCapabilities);
103
129
  try {
104
130
  const response = await this.harness({ role: "planner", complexity: "complex" }, directory, mcp.tools).run(prompt);
@@ -27,6 +27,7 @@ export class AzureResponsesHarness {
27
27
  tools,
28
28
  fetch = globalThis.fetch,
29
29
  maxSteps = 40,
30
+ maxOutputTokens = 4096,
30
31
  retries = 4,
31
32
  timeoutMs = 180_000,
32
33
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -37,6 +38,7 @@ export class AzureResponsesHarness {
37
38
  this.tools = tools;
38
39
  this.fetch = fetch;
39
40
  this.maxSteps = maxSteps;
41
+ this.maxOutputTokens = maxOutputTokens;
40
42
  this.retries = retries;
41
43
  this.timeoutMs = timeoutMs;
42
44
  this.sleep = sleep;
@@ -79,15 +81,20 @@ export class AzureResponsesHarness {
79
81
  let input = prompt;
80
82
  let previousResponseId;
81
83
  const definitions = toolDefinitions(this.tools);
84
+ const usage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
82
85
  for (let step = 0; step < this.maxSteps; step += 1) {
83
86
  const response = await this.request({
84
87
  model: this.model,
85
88
  input,
86
89
  ...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
87
90
  tools: definitions,
91
+ max_output_tokens: this.maxOutputTokens,
88
92
  });
93
+ usage.inputTokens += response.usage?.input_tokens ?? 0;
94
+ usage.cachedInputTokens += response.usage?.input_tokens_details?.cached_tokens ?? 0;
95
+ usage.outputTokens += response.usage?.output_tokens ?? 0;
89
96
  const calls = (response.output ?? []).filter((item) => item.type === "function_call");
90
- if (calls.length === 0) return { text: outputText(response), responseId: response.id, steps: step + 1 };
97
+ if (calls.length === 0) return { text: outputText(response), responseId: response.id, steps: step + 1, usage };
91
98
  const outputs = [];
92
99
  for (const call of calls) {
93
100
  const tool = this.tools[call.name];
@@ -11,11 +11,12 @@ function definitions(tools) {
11
11
  }
12
12
 
13
13
  export class BedrockHarness {
14
- constructor({ client, region = process.env.AWS_REGION ?? "us-east-1", model, tools, maxSteps = 40 }) {
14
+ constructor({ client, region = process.env.AWS_REGION ?? "us-east-1", model, tools, maxSteps = 40, maxOutputTokens = 4096 }) {
15
15
  this.client = client ?? new BedrockRuntimeClient({ region });
16
16
  this.model = model;
17
17
  this.tools = tools;
18
18
  this.maxSteps = maxSteps;
19
+ this.maxOutputTokens = maxOutputTokens;
19
20
  }
20
21
 
21
22
  async run(prompt) {
@@ -25,6 +26,7 @@ export class BedrockHarness {
25
26
  const response = await this.client.send(new ConverseCommand({
26
27
  modelId: this.model,
27
28
  messages,
29
+ inferenceConfig: { maxTokens: this.maxOutputTokens },
28
30
  ...(Object.keys(this.tools).length ? { toolConfig: { tools: definitions(this.tools) } } : {}),
29
31
  }));
30
32
  usage.inputTokens += response.usage?.inputTokens ?? 0;
package/src/dashboard.js CHANGED
@@ -123,6 +123,20 @@ export function aggregateDashboard({ states = [], queue = {}, cost = null, runti
123
123
  });
124
124
  const summary = {};
125
125
  for (const objective of objectives) summary[objective.status ?? "unknown"] = (summary[objective.status ?? "unknown"] ?? 0) + 1;
126
+ const modelUsage = {};
127
+ for (const state of states) {
128
+ for (const result of Object.values(state.results ?? {})) {
129
+ const telemetry = result.telemetry;
130
+ if (!telemetry?.model) continue;
131
+ const current = modelUsage[telemetry.model] ?? { tasks: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, durationMs: 0 };
132
+ current.tasks += 1;
133
+ current.inputTokens += telemetry.usage?.inputTokens ?? 0;
134
+ current.cachedInputTokens += telemetry.usage?.cachedInputTokens ?? 0;
135
+ current.outputTokens += telemetry.usage?.outputTokens ?? 0;
136
+ current.durationMs += telemetry.durationMs ?? 0;
137
+ modelUsage[telemetry.model] = current;
138
+ }
139
+ }
126
140
  const startedAt = runtime.startedAt ? new Date(runtime.startedAt) : null;
127
141
  return {
128
142
  generatedAt: now.toISOString(),
@@ -133,6 +147,7 @@ export function aggregateDashboard({ states = [], queue = {}, cost = null, runti
133
147
  queue: { active: queue.active ?? 0, deadLetter: queue.deadLetter ?? 0 },
134
148
  cost,
135
149
  summary: { objectives: summary },
150
+ modelUsage,
136
151
  objectives,
137
152
  warnings,
138
153
  };
@@ -150,6 +165,10 @@ export function renderDashboard(dashboard, { width = 100 } = {}) {
150
165
  `Objectives ${Object.entries(dashboard.summary.objectives).map(([state, count]) => `${state}:${count}`).join(" ") || "none"}`,
151
166
  ];
152
167
  if (dashboard.cost) lines.push(`Azure MTD ${dashboard.cost.currency} ${dashboard.cost.monthToDate.toFixed(2)} · billing data may be delayed`);
168
+ const totalInput = Object.values(dashboard.modelUsage ?? {}).reduce((sum, item) => sum + item.inputTokens, 0);
169
+ const totalCached = Object.values(dashboard.modelUsage ?? {}).reduce((sum, item) => sum + item.cachedInputTokens, 0);
170
+ const totalOutput = Object.values(dashboard.modelUsage ?? {}).reduce((sum, item) => sum + item.outputTokens, 0);
171
+ if (totalInput || totalOutput) lines.push(`Tokens input ${totalInput} · cached ${totalCached} · output ${totalOutput}`);
153
172
  for (const objective of dashboard.objectives) {
154
173
  lines.push("", `[${objective.status}] ${objective.id} ${objective.objective}`);
155
174
  for (const task of objective.tasks) lines.push(` ${task.state.padEnd(9)} ${task.role.padEnd(9)} ${task.model} · ${task.title ?? task.id}`);
package/src/mcp-tools.js CHANGED
@@ -1,5 +1,6 @@
1
1
  function renderContent(result) {
2
- return (result.content ?? []).map((item) => item.type === "text" ? item.text : JSON.stringify(item)).join("\n");
2
+ const value = (result.content ?? []).map((item) => item.type === "text" ? item.text : JSON.stringify(item)).join("\n");
3
+ return value.length > 32_000 ? `${value.slice(0, 32_000)}\n[TRUNCATED: request narrower MCP output]` : value;
3
4
  }
4
5
 
5
6
  async function defaultConnect(capability) {
package/src/operator.js CHANGED
@@ -21,7 +21,16 @@ export function createOperator(environment = process.env) {
21
21
  const vm = environment.FACTORY_VM ?? "agent-factory-vm";
22
22
  const namespace = environment.FACTORY_SERVICE_BUS ?? "";
23
23
  const vault = environment.FACTORY_KEY_VAULT ?? "";
24
- const remote = async (script) => extractRunCommand(await command("az", ["vm", "run-command", "invoke", "--resource-group", resourceGroup, "--name", vm, "--command-id", "RunShellScript", "--scripts", script, "--query", "value[0].message", "--output", "tsv"]));
24
+ const remote = async (script) => {
25
+ for (let attempt = 0; attempt < 6; attempt += 1) {
26
+ try { return extractRunCommand(await command("az", ["vm", "run-command", "invoke", "--resource-group", resourceGroup, "--name", vm, "--command-id", "RunShellScript", "--scripts", script, "--query", "value[0].message", "--output", "tsv"])); }
27
+ catch (error) {
28
+ if (!error.message.includes("Conflict") || attempt === 5) throw error;
29
+ await new Promise((resolve) => setTimeout(resolve, 10_000));
30
+ }
31
+ }
32
+ throw new Error("Azure Run Command remained busy");
33
+ };
25
34
  const withVault = async (operation) => {
26
35
  const ip = await command("curl", ["-fsS", "https://api.ipify.org"]);
27
36
  await command("az", ["keyvault", "network-rule", "add", "--name", vault, "--ip-address", `${ip}/32`, "--output", "none"]);
@@ -47,7 +56,7 @@ export function createOperator(environment = process.env) {
47
56
  return command(path.join(root, "bin/factory"), [action]);
48
57
  },
49
58
  capabilities: async () => JSON.parse(await readFile(path.join(root, "config/capabilities.json"), "utf8")),
50
- config: () => ({ resourceGroup, vm, namespace, vault }),
59
+ config: () => ({ resourceGroup, vm, namespace, vault, models: { scout: "GPT-5.4 nano", simpleBuilder: "Kimi K2.7-Code", builder: "GPT-5.5", tester: "GPT-5.4", critical: "GPT-5.6" } }),
51
60
  listSecrets: async () => withVault(async () => JSON.parse(await command("az", ["keyvault", "secret", "list", "--vault-name", vault, "--query", "[].{name:name,updated:attributes.updated}", "--output", "json"]))),
52
61
  setSecret: async (name, value) => withVault(async () => {
53
62
  if (!/^[A-Za-z0-9-]{1,127}$/.test(name) || !value) throw new Error("Valid secret name and value are required");
@@ -12,11 +12,19 @@ export class ProjectMemory {
12
12
  await appendFile(this.file, `${JSON.stringify({ ...event, recordedAt: new Date().toISOString() })}\n`, { mode: 0o640 });
13
13
  }
14
14
 
15
- async context(repository, limit = 20) {
15
+ async context(repository, limit = 8) {
16
16
  let text;
17
17
  try { text = await readFile(this.file, "utf8"); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
18
18
  return text.split("\n").filter(Boolean).flatMap((line) => {
19
19
  try { const value = JSON.parse(line); return value.repository === repository ? [value] : []; } catch { return []; }
20
- }).slice(-limit);
20
+ }).slice(-limit).map((value) => ({
21
+ type: value.type,
22
+ objectiveId: value.objectiveId,
23
+ objective: String(value.objective ?? "").slice(0, 1000),
24
+ executiveIntent: String(value.executiveIntent ?? "").slice(0, 1000),
25
+ pullRequest: value.pullRequest,
26
+ blockers: (value.blockers ?? []).slice(0, 10).map((item) => String(item).slice(0, 300)),
27
+ recordedAt: value.recordedAt,
28
+ }));
21
29
  }
22
30
  }
package/src/routing.js CHANGED
@@ -13,7 +13,7 @@ const MODELS = Object.freeze({
13
13
  scout: "azureai-textved/factory-gpt-5-4-nano",
14
14
  tester: "azureai-responses/gpt-5.4",
15
15
  planner: "azureai-textved/gpt-5.6-sol",
16
- builder: "azureai-textved/gpt-5.6-sol",
16
+ builder: "azureai-textved/gpt-5.5",
17
17
  debugger: "azureai-textved/gpt-5.6-sol",
18
18
  reviewer: "azureai-textved/gpt-5.6-sol",
19
19
  security: "azureai-textved/gpt-5.6-sol",
@@ -26,9 +26,10 @@ const scanners = [
26
26
 
27
27
  function redact(value) {
28
28
  return String(value)
29
+ .replaceAll(/\u001b\[[0-9;]*m/g, "")
29
30
  .replaceAll(/((?:api[_-]?key|token|secret|password)\s*[=:]\s*)\S+/gi, "$1[REDACTED]")
30
31
  .replaceAll(/\b[A-Za-z0-9+/_=-]{48,}\b/g, "[REDACTED]")
31
- .slice(-20_000);
32
+ .slice(-6000);
32
33
  }
33
34
 
34
35
  export class ScannerSuite {
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { createHash } from "node:crypto";
4
5
  import { DefaultAzureCredential } from "@azure/identity";
5
6
  import { ServiceBusClient } from "@azure/service-bus";
6
7
  import { loadConfig } from "./config.js";
7
8
  import { loadRuntimeSecrets } from "./secrets.js";
8
9
  import { sendMessage } from "./bus.js";
9
10
  import { loadLocalState, loadQueueMetrics } from "./dashboard.js";
10
- import { isAllowedChat, objectiveFromTelegram, parseTelegramCommand } from "./telegram.js";
11
+ import { formatObjectiveProgress, isAllowedChat, objectiveFromTelegram, parseTelegramCommand } from "./telegram.js";
11
12
  import { log } from "./log.js";
12
13
 
13
14
  const config = loadConfig();
@@ -21,9 +22,16 @@ if (!token || allowed.size === 0) {
21
22
 
22
23
  const directory = path.join(config.stateDir, "telegram");
23
24
  const offsetFile = path.join(directory, "offset");
25
+ const preferencesFile = path.join(directory, "preferences.json");
26
+ const subscriptionsFile = path.join(directory, "subscriptions.json");
24
27
  await mkdir(directory, { recursive: true, mode: 0o750 });
25
28
  let offset = 0;
26
29
  try { offset = Number(await readFile(offsetFile, "utf8")) || 0; } catch (error) { if (error.code !== "ENOENT") throw error; }
30
+ async function loadJson(file) {
31
+ try { return JSON.parse(await readFile(file, "utf8")); } catch (error) { if (error.code === "ENOENT") return {}; throw error; }
32
+ }
33
+ const preferences = await loadJson(preferencesFile);
34
+ const subscriptions = await loadJson(subscriptionsFile);
27
35
 
28
36
  const client = new ServiceBusClient(config.serviceBusFqdn, new DefaultAzureCredential());
29
37
  const sender = client.createSender(config.controlQueue);
@@ -51,6 +59,12 @@ async function saveOffset(value) {
51
59
  await rename(temporary, offsetFile);
52
60
  }
53
61
 
62
+ async function saveJson(file, value) {
63
+ const temporary = `${file}.${process.pid}.tmp`;
64
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o640 });
65
+ await rename(temporary, file);
66
+ }
67
+
54
68
  async function statusText() {
55
69
  const [{ states }, queue] = await Promise.all([loadLocalState(config.stateDir), loadQueueMetrics(config)]);
56
70
  const counts = {};
@@ -62,17 +76,52 @@ async function processUpdate(update) {
62
76
  const message = update.message;
63
77
  if (!message?.text || !isAllowedChat(message.chat?.id, allowed)) return;
64
78
  try {
65
- const command = parseTelegramCommand(message.text);
66
- if (command.type === "help") return reply(message.chat.id, "/submit OWNER/REPO objective\n/goal OWNER/REPO objective\n/loop OWNER/REPO objective\n/status\n/help");
79
+ const chatId = String(message.chat.id);
80
+ const command = parseTelegramCommand(message.text, preferences[chatId]?.repository);
81
+ if (command.type === "help") return reply(message.chat.id, "/repo OWNER/REPO\nPlain text objective\n/submit OWNER/REPO objective\n/goal [OWNER/REPO] objective\n/loop [OWNER/REPO] objective\n/status\n/recent\n/objective ID\n/help");
67
82
  if (command.type === "status") return reply(message.chat.id, await statusText());
83
+ if (command.type === "set_repository") {
84
+ preferences[chatId] = { repository: command.repository, updatedAt: new Date().toISOString() };
85
+ await saveJson(preferencesFile, preferences);
86
+ return reply(message.chat.id, `Default repository set to ${command.repository}. You can now send plain-text objectives.`);
87
+ }
88
+ if (command.type === "recent") {
89
+ const { states } = await loadLocalState(config.stateDir);
90
+ const text = states.slice(-10).reverse().map((state) => `${state.status ?? "unknown"} — ${state.objective?.id}\n${String(state.objective?.objective ?? "").slice(0, 150)}`).join("\n\n") || "No objectives.";
91
+ return reply(message.chat.id, text);
92
+ }
93
+ if (command.type === "objective") {
94
+ const state = JSON.parse(await readFile(path.join(config.stateDir, command.objectiveId, "state.json"), "utf8"));
95
+ return reply(message.chat.id, formatObjectiveProgress(state));
96
+ }
68
97
  const objective = objectiveFromTelegram(update.update_id, command);
69
98
  await sendMessage(sender, objective, objective.id);
99
+ subscriptions[objective.id] = { chatId, lastDigest: "", createdAt: new Date().toISOString() };
100
+ await saveJson(subscriptionsFile, subscriptions);
70
101
  await reply(message.chat.id, `Queued ${objective.id}\n${command.repository}\n${command.objective}`);
71
102
  } catch (error) {
72
103
  await reply(message.chat.id, `Rejected: ${String(error.message ?? error).slice(0, 500)}`);
73
104
  }
74
105
  }
75
106
 
107
+ async function notifyProgress() {
108
+ let changed = false;
109
+ for (const [objectiveId, subscription] of Object.entries(subscriptions)) {
110
+ let state;
111
+ try { state = JSON.parse(await readFile(path.join(config.stateDir, objectiveId, "state.json"), "utf8")); }
112
+ catch (error) { if (error.code === "ENOENT") continue; throw error; }
113
+ const text = formatObjectiveProgress(state);
114
+ const digest = createHash("sha256").update(text).digest("hex");
115
+ if (digest === subscription.lastDigest) continue;
116
+ await reply(subscription.chatId, text);
117
+ subscription.lastDigest = digest;
118
+ subscription.lastStatus = state.status;
119
+ subscription.notifiedAt = new Date().toISOString();
120
+ changed = true;
121
+ }
122
+ if (changed) await saveJson(subscriptionsFile, subscriptions);
123
+ }
124
+
76
125
  async function shutdown(signal) {
77
126
  log("info", "telegram_shutdown", { signal });
78
127
  abort.abort();
@@ -91,6 +140,7 @@ while (!abort.signal.aborted) {
91
140
  offset = update.update_id + 1;
92
141
  await saveOffset(offset);
93
142
  }
143
+ await notifyProgress();
94
144
  } catch (error) {
95
145
  if (abort.signal.aborted) break;
96
146
  log("error", "telegram_poll_failed", { error: String(error.message ?? error).replaceAll(token, "[REDACTED]").slice(0, 1000) });
package/src/telegram.js CHANGED
@@ -4,18 +4,26 @@ export function isAllowedChat(chatId, allowed) {
4
4
  return allowed.size > 0 && allowed.has(String(chatId));
5
5
  }
6
6
 
7
- export function parseTelegramCommand(input) {
7
+ export function parseTelegramCommand(input, defaultRepository) {
8
8
  const text = String(input ?? "").trim();
9
9
  const [rawCommand, repository, ...words] = text.split(/\s+/);
10
10
  const command = rawCommand?.split("@")[0];
11
- if (["/status", "/help"].includes(command) && !repository) return { type: command.slice(1) };
11
+ if (!text.startsWith("/") && defaultRepository) {
12
+ if (text.length > 8000) throw new Error("Objective is too long");
13
+ return { type: "submit", repository: defaultRepository, objective: text };
14
+ }
15
+ if (["/status", "/help", "/recent"].includes(command) && !repository) return { type: command.slice(1) };
16
+ if (command === "/objective" && /^[A-Za-z0-9_-]{1,64}$/.test(repository ?? "") && words.length === 0) return { type: "objective", objectiveId: repository };
17
+ if (command === "/repo" && repositoryPattern.test(repository ?? "") && words.length === 0) return { type: "set_repository", repository };
12
18
  if (!["/submit", "/goal", "/loop"].includes(command)) throw new Error("Unknown command. Use /help");
13
- if (!repositoryPattern.test(repository ?? "")) throw new Error("Repository must be OWNER/REPO");
14
- const objective = words.join(" ").trim();
19
+ const explicitRepository = repositoryPattern.test(repository ?? "");
20
+ const selectedRepository = explicitRepository ? repository : defaultRepository;
21
+ if (!repositoryPattern.test(selectedRepository ?? "")) throw new Error("Repository must be OWNER/REPO or configured with /repo");
22
+ const objective = (explicitRepository ? words : [repository, ...words]).join(" ").trim();
15
23
  if (objective.length < 3) throw new Error("Objective is required");
16
24
  if (objective.length > 8000) throw new Error("Objective is too long");
17
25
  const prefix = command === "/submit" ? "" : `${command} `;
18
- return { type: "submit", repository, objective: `${prefix}${objective}` };
26
+ return { type: "submit", repository: selectedRepository, objective: `${prefix}${objective}` };
19
27
  }
20
28
 
21
29
  export function objectiveFromTelegram(updateId, command, now = new Date()) {
@@ -29,3 +37,19 @@ export function objectiveFromTelegram(updateId, command, now = new Date()) {
29
37
  createdAt: now.toISOString(),
30
38
  };
31
39
  }
40
+
41
+ export function formatObjectiveProgress(state) {
42
+ const tasks = state.tasks ?? [];
43
+ const results = state.results ?? {};
44
+ const complete = tasks.filter((task) => results[task.id]?.status === "succeeded").length;
45
+ const lines = [
46
+ `Factory AI objective ${state.objective?.id ?? "unknown"}`,
47
+ state.objective?.objective ?? "",
48
+ `Status: ${state.status ?? "unknown"}`,
49
+ `${complete}/${tasks.length} tasks complete`,
50
+ ];
51
+ for (const task of tasks.slice(0, 20)) lines.push(`${task.role}: ${results[task.id]?.status ?? "blocked"} — ${task.title}`);
52
+ if (state.failure) lines.push(`Blocker: ${String(state.failure).slice(0, 500)}`);
53
+ if (state.release?.url) lines.push(`PR: ${state.release.url}`);
54
+ return lines.join("\n").slice(0, 4000);
55
+ }
package/src/tui.js CHANGED
@@ -18,6 +18,7 @@ let capabilities;
18
18
  let secrets;
19
19
  let logs;
20
20
  let section = "Overview";
21
+ let refreshing = false;
21
22
 
22
23
  function status(message, color = colors.muted) {
23
24
  footer.setContent(` {${color}-fg}${message}{/} {bold}n{/} new {bold}r{/} refresh {bold}q{/} quit`);
@@ -32,8 +33,12 @@ function badge(value) {
32
33
  function renderOverview() {
33
34
  const cost = dashboard?.cost ? `${dashboard.cost.currency} ${dashboard.cost.monthToDate.toFixed(2)}` : "unavailable";
34
35
  const counts = Object.entries(dashboard?.summary?.objectives ?? {}).map(([key, value]) => `${key} ${value}`).join(" ") || "none";
36
+ const modelUsage = Object.values(dashboard?.modelUsage ?? {});
37
+ const inputTokens = modelUsage.reduce((sum, item) => sum + item.inputTokens, 0);
38
+ const cachedTokens = modelUsage.reduce((sum, item) => sum + item.cachedInputTokens, 0);
39
+ const outputTokens = modelUsage.reduce((sum, item) => sum + item.outputTokens, 0);
35
40
  const recent = (dashboard?.objectives ?? []).slice(-8).reverse().map((objective) => ` ${badge(objective.status.padEnd(9))} {bold}${objective.objective}{/}\n ${objective.repository ?? ""}`).join("\n\n");
36
- main.setContent(`{bold}System{/}\n\n Queue {#78dba9-fg}${dashboard?.queue?.active ?? 0}{/}\n Dead letter ${dashboard?.queue?.deadLetter ?? 0}\n Azure MTD {#efc46b-fg}${cost}{/}\n Objectives ${counts}\n\n{bold}Recent objectives{/}\n\n${recent || " No objectives yet."}`);
41
+ main.setContent(`{bold}System{/}\n\n Queue {#78dba9-fg}${dashboard?.queue?.active ?? 0}{/}\n Dead letter ${dashboard?.queue?.deadLetter ?? 0}\n Azure MTD {#efc46b-fg}${cost}{/}\n Objectives ${counts}\n Tokens ${inputTokens} in · ${cachedTokens} cached · ${outputTokens} out\n\n{bold}Recent objectives{/}\n\n${recent || " No objectives yet."}`);
37
42
  }
38
43
 
39
44
  function renderObjectives() {
@@ -60,7 +65,7 @@ function renderCapabilities() {
60
65
 
61
66
  function renderSettings() {
62
67
  const config = operator.config();
63
- main.setContent(`{bold}Runtime{/}\n\n Resource group ${config.resourceGroup}\n VM ${config.vm}\n Service Bus ${config.namespace}\n Key Vault ${config.vault}\n\n{bold}Model policy{/}\n\n Scout GPT-5.4 nano\n Simple builder Kimi K2.7-Code\n Builder GPT-5.6\n Tester GPT-5.4\n Critical roles GPT-5.6\n\n{#7f8b99-fg}Run factory setup to change providers or role routes.{/}`);
68
+ main.setContent(`{bold}Runtime{/}\n\n Resource group ${config.resourceGroup}\n VM ${config.vm}\n Service Bus ${config.namespace}\n Key Vault ${config.vault}\n\n{bold}Model policy{/}\n\n Scout GPT-5.4 nano\n Simple builder Kimi K2.7-Code\n Builder GPT-5.5\n Tester GPT-5.4\n Critical roles GPT-5.6\n\n{#7f8b99-fg}Run factory setup to change providers or role routes.{/}`);
64
69
  }
65
70
 
66
71
  function render() {
@@ -77,6 +82,8 @@ function render() {
77
82
  }
78
83
 
79
84
  async function refresh() {
85
+ if (refreshing) return;
86
+ refreshing = true;
80
87
  status("Refreshing...");
81
88
  try {
82
89
  dashboard = await operator.dashboard();
@@ -86,6 +93,7 @@ async function refresh() {
86
93
  render();
87
94
  status(`Updated ${new Date().toLocaleTimeString()}`);
88
95
  } catch (error) { status(error.message, colors.danger); }
96
+ finally { refreshing = false; }
89
97
  }
90
98
 
91
99
  function ask(label, { secret = false } = {}) {
package/src/updater.js ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ function parts(value) {
6
+ if (!/^\d+\.\d+\.\d+$/.test(value)) throw new Error(`Unsupported version: ${value}`);
7
+ return value.split(".").map(Number);
8
+ }
9
+
10
+ export function compareVersions(left, right) {
11
+ const a = parts(left);
12
+ const b = parts(right);
13
+ for (let index = 0; index < 3; index += 1) {
14
+ if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1;
15
+ }
16
+ return 0;
17
+ }
18
+
19
+ export function shouldAutoUpdate(current, latest) {
20
+ const currentParts = parts(current);
21
+ const latestParts = parts(latest);
22
+ return currentParts[0] === latestParts[0] && compareVersions(latest, current) > 0;
23
+ }
24
+
25
+ async function main() {
26
+ const packageFile = process.env.FACTORY_PACKAGE_FILE ?? "/opt/agent-factory/app/package.json";
27
+ const current = JSON.parse(await readFile(packageFile, "utf8")).version;
28
+ const response = await fetch("https://registry.npmjs.org/factory-ai/latest", { signal: AbortSignal.timeout(30_000) });
29
+ if (!response.ok) throw new Error(`npm registry HTTP ${response.status}`);
30
+ const latestPackage = await response.json();
31
+ process.stdout.write(`${JSON.stringify({
32
+ current,
33
+ latest: latestPackage.version,
34
+ gitHead: latestPackage.gitHead,
35
+ updateAvailable: shouldAutoUpdate(current, latestPackage.version),
36
+ })}\n`);
37
+ }
38
+
39
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main().catch((error) => {
40
+ process.stderr.write(`${error.message}\n`);
41
+ process.exitCode = 1;
42
+ });
package/src/validation.js CHANGED
@@ -36,6 +36,16 @@ const taskResultSchema = z.object({
36
36
  checks: z.array(z.string().trim().min(1).max(1000)).max(50),
37
37
  risks: z.array(z.string().trim().min(1).max(1000)).max(50),
38
38
  approval: z.enum(["approved", "changes_requested", "not_applicable"]),
39
+ telemetry: z.object({
40
+ model: z.string().min(1).max(300),
41
+ steps: z.number().int().nonnegative(),
42
+ durationMs: z.number().int().nonnegative(),
43
+ usage: z.object({
44
+ inputTokens: z.number().int().nonnegative().default(0),
45
+ cachedInputTokens: z.number().int().nonnegative().default(0),
46
+ outputTokens: z.number().int().nonnegative().default(0),
47
+ }),
48
+ }).optional(),
39
49
  }).strict();
40
50
 
41
51
  export const workMessageSchema = z.object({
@@ -47,17 +47,25 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
47
47
  const root = realpathSync(path.resolve(rootInput));
48
48
  return {
49
49
  read_file: {
50
- description: "Read a UTF-8 file inside the assigned workspace.",
50
+ description: "Read a bounded UTF-8 line range inside the assigned workspace. Use offsetLine/limitLines instead of rereading large files.",
51
51
  parameters: {
52
52
  type: "object",
53
- properties: { path: { type: "string" } },
53
+ properties: {
54
+ path: { type: "string" },
55
+ offsetLine: { type: "integer", minimum: 1 },
56
+ limitLines: { type: "integer", minimum: 1, maximum: 2000 },
57
+ },
54
58
  required: ["path"],
55
59
  additionalProperties: false,
56
60
  },
57
- execute: async ({ path: requested }) => {
61
+ execute: async ({ path: requested, offsetLine = 1, limitLines = 400 }) => {
58
62
  const value = await readFile(await existingPath(root, requested), "utf8");
59
- if (Buffer.byteLength(value) > 512_000) throw new Error("File exceeds 512000 bytes");
60
- return value;
63
+ const lines = value.split("\n");
64
+ const start = offsetLine - 1;
65
+ let output = lines.slice(start, start + limitLines).join("\n");
66
+ if (start + limitLines < lines.length) output += `\n[TRUNCATED: ${lines.length - start - limitLines} more lines; request the next range]`;
67
+ if (Buffer.byteLength(output) > 256_000) output = `${output.slice(0, 256_000)}\n[TRUNCATED: byte limit]`;
68
+ return output;
61
69
  },
62
70
  },
63
71
  write_file: {
@@ -81,14 +89,14 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
81
89
  description: "List files recursively inside the assigned workspace.",
82
90
  parameters: {
83
91
  type: "object",
84
- properties: { path: { type: "string" } },
92
+ properties: { path: { type: "string" }, limit: { type: "integer", minimum: 1, maximum: 2000 } },
85
93
  required: ["path"],
86
94
  additionalProperties: false,
87
95
  },
88
- execute: async ({ path: requested }) => {
96
+ execute: async ({ path: requested, limit = 500 }) => {
89
97
  const directory = await existingPath(root, requested);
90
98
  const output = [];
91
- await walk(directory, root, output, 2000);
99
+ await walk(directory, root, output, limit);
92
100
  return JSON.stringify(output);
93
101
  },
94
102
  },
@@ -110,11 +118,12 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
110
118
  const result = await execute(command, args, {
111
119
  cwd: root,
112
120
  timeoutMs: 900_000,
113
- maxOutputBytes: 2_000_000,
121
+ maxOutputBytes: 500_000,
114
122
  inheritEnv: false,
115
123
  env: { PATH: process.env.PATH, HOME: "/tmp/agent-home", CI: "true", NO_COLOR: "1" },
116
124
  });
117
- return `${result.stdout}${result.stderr ? `\nSTDERR:\n${result.stderr}` : ""}`.slice(-2_000_000);
125
+ const output = `${result.stdout}${result.stderr ? `\nSTDERR:\n${result.stderr}` : ""}`;
126
+ return output.length > 120_000 ? `[TRUNCATED: showing final 120000 characters]\n${output.slice(-120_000)}` : output;
118
127
  },
119
128
  },
120
129
  };