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 +1 -1
- package/CHANGELOG.md +16 -0
- package/README.md +29 -3
- package/RUNBOOK.md +2 -0
- package/bootstrap/auto-update.sh +61 -0
- package/bootstrap/deploy-runtime.sh +8 -0
- package/bootstrap/factory-ai-update.service +17 -0
- package/bootstrap/factory-ai-update.timer +10 -0
- package/bootstrap/setup.sh +4 -0
- package/package.json +1 -1
- package/src/agent-runner.js +39 -13
- package/src/azure-harness.js +8 -1
- package/src/bedrock-harness.js +3 -1
- package/src/dashboard.js +19 -0
- package/src/mcp-tools.js +2 -1
- package/src/operator.js +11 -2
- package/src/project-memory.js +10 -2
- package/src/routing.js +1 -1
- package/src/scanner-suite.js +2 -1
- package/src/telegram-service.js +53 -3
- package/src/telegram.js +29 -5
- package/src/tui.js +10 -2
- package/src/updater.js +42 -0
- package/src/validation.js +10 -0
- package/src/workspace-tools.js +19 -10
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.
|
|
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.
|
|
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
|
-
|
|
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
|
package/bootstrap/setup.sh
CHANGED
|
@@ -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
|
|
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",
|
package/src/agent-runner.js
CHANGED
|
@@ -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
|
|
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
|
|
59
|
-
|
|
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
|
-
|
|
77
|
+
...budget,
|
|
66
78
|
});
|
|
67
79
|
}
|
|
68
|
-
return this.createHarness({ ...endpointForRoute(route, role, this.environment), tools, timeoutMs: this.config.timeoutMs,
|
|
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
|
|
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
|
|
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);
|
package/src/azure-harness.js
CHANGED
|
@@ -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];
|
package/src/bedrock-harness.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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");
|
package/src/project-memory.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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",
|
package/src/scanner-suite.js
CHANGED
|
@@ -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(-
|
|
32
|
+
.slice(-6000);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export class ScannerSuite {
|
package/src/telegram-service.js
CHANGED
|
@@ -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
|
|
66
|
-
|
|
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 (
|
|
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
|
-
|
|
14
|
-
const
|
|
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
|
|
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.
|
|
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({
|
package/src/workspace-tools.js
CHANGED
|
@@ -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
|
|
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: {
|
|
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
|
-
|
|
60
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
};
|