Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/public-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
cache: npm
- run: npm ci
- run: npm audit --omit=dev --audit-level=high
- run: node --test test/cloud-lite.test.mjs test/public-sdk.test.mjs test/template-catalog.test.mjs test/factory-team-template.test.mjs test/flujo-workspace.test.mjs test/savia-case-plan.test.mjs test/savia-topology.test.mjs test/managed-fleet.test.mjs test/managed-fleet-real-module.test.mjs test/managed-fleet-provider-closure.test.mjs test/provider-cell-retirement.test.mjs test/presentation.test.mjs test/presentation-modal.test.mjs test/presentation-closure.test.mjs test/observatory-client.test.mjs test/workflows.test.mjs test/workflow-native-wake.test.mjs test/workflow-m0-lifecycle.test.mjs test/workflow-m1-portable-artifacts.test.mjs test/workflow-m2-ack-recovery.test.mjs test/workflow-m2-authority-recovery.test.mjs
- run: node --test test/cloud-lite.test.mjs test/cloud-lite-provider.test.mjs test/public-sdk.test.mjs test/template-catalog.test.mjs test/factory-team-template.test.mjs test/flujo-workspace.test.mjs test/savia-case-plan.test.mjs test/savia-topology.test.mjs test/managed-fleet.test.mjs test/managed-fleet-real-module.test.mjs test/managed-fleet-provider-closure.test.mjs test/provider-cell-retirement.test.mjs test/presentation.test.mjs test/presentation-modal.test.mjs test/presentation-closure.test.mjs test/observatory-client.test.mjs test/workflows.test.mjs test/workflow-native-wake.test.mjs test/workflow-m0-lifecycle.test.mjs test/workflow-m1-portable-artifacts.test.mjs test/workflow-m2-ack-recovery.test.mjs test/workflow-m2-authority-recovery.test.mjs
- name: Install and exercise the packed public package
shell: bash
run: |
Expand Down
100 changes: 97 additions & 3 deletions PUBLIC_SDK.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
# Public local Factory SDK, CLI, and MCP

Version 0.3.0 is distributed as a versioned package in
[GitHub Releases](https://github.com/flujo-app/FACTORY/releases/tag/v0.3.0):
Version 0.4.0 is distributed as a versioned package in
[GitHub Releases](https://github.com/flujo-app/FACTORY/releases/tag/v0.4.0):

```sh
npm install https://github.com/flujo-app/FACTORY/releases/download/v0.3.0/flujo-factory-0.3.0.tgz
npm install https://github.com/flujo-app/FACTORY/releases/download/v0.4.0/flujo-factory-0.4.0.tgz
```

0.4.0 lets cloud lite workers run on a self-hosted OpenAI-compatible model,
clone with a read-only deploy key, prepare agent folders, take operator-driven
jobs in a narrower sandbox, and record far less trace volume. See
[Self-hosted models and operator-driven jobs](#self-hosted-models-and-operator-driven-jobs).
All new plan settings are optional; existing plans behave as before, except
that streaming deltas are no longer traced unless `traceDeltas` is set.

`FactoryControl` and `executeEffect` are public exports for integrations that
retain FACTORY's durable admission while using an existing fleet executor.
Repeating an effect key observes its original outcome without dispatching again.
Expand Down Expand Up @@ -243,6 +250,93 @@ while workers execute; a disconnected host can prevent authentication renewal.
The runtime does not switch to an API key. Credentials never belong in an image,
public journal, source commit or trace. Store the fleet directory privately.

#### Self-hosted models and operator-driven jobs

Lite workers can run on a self-hosted, OpenAI-compatible model instead of the
subscription login. Codex reaches it through the Responses API
(`/v1/responses`), which vLLM serves for every model. Add `modelProvider` to the
plan and pass its key as `providerKey`; no `auth` broker is needed. A read-only
GitHub deploy key can replace the GitHub token:

```js
const fleet = new FactoryCloudLiteFleet({ directory: '/absolute/private/fleet-run',
flujoCloudPath: '/absolute/installed/flujo-cloud' });
await fleet.deploy({
runId: 'my-selfhosted-run', app: 'my-selfhosted-app', goal: ownerGoal,
repository: 'owner/project', repositoryRevision: pinnedCommitSha,
machines: 10, agentsPerMachine: 19,
model: 'nemotron-3.5-super-vl', subagentModel: 'nemotron-3.5-super-vl', reasoningEffort: 'medium',
modelProvider: {
id: 'selfhosted', name: 'Self-hosted model', envKey: 'SELF_HOSTED_MODEL_KEY',
baseUrls: ['https://model-a.example/v1', 'https://model-b.example/v1'],
contextWindow: 262144, autoCompactTokenLimit: 80000,
},
supervision: 'operator', sandbox: 'workspace-write', boardTools: false, monitorIntervalMs: 4000,
agentInstructions: 'Wait for operator jobs and work only in your folder.',
prepare: { argv: ['node', 'tools/prepare-agents.mjs'], spec: { jobsPerMachine: 18 } },
}, { image: immutableWorkerImage, bundlePath: '/absolute/private/flujo-factory.tgz',
providerKey: privateEndpointKey, deployKey: readOnlyDeployKey });
await fleet.command('m0-a1', { id: 'job-1', action: 'message', message: 'First job ...' });
```

- **`modelProvider`**: `id`, `envKey` (the variable that carries the key) and
`https` `baseUrls` without credentials are required; `name`, `model`,
`contextWindow`, `autoCompactTokenLimit`, `toolOutputTokenLimit`,
`requestMaxRetries`, `streamMaxRetries` and `streamIdleTimeoutMs` are optional.
Each Codex gets a `config.toml` with the provider, the model's real context
window and its compaction point; web search, multi-agent, apps, plugins, goals,
memories and skill instructions are off, and agents' shell commands inherit only
the core environment. The key is a staged Fly app secret placed only in the
Codex process environment; agent commands, journals and traces do not get it.
Machine *i* uses `baseUrls[i % n]`, so each machine keeps its prompt cache on one
server. Codex offers no `apply_patch` tool to an unknown model, so ask agents to
edit files with shell commands.
- **`supervision: 'operator'`**: agents start idle. There is no automatic first
turn, follow-up or wake; every turn comes from a durable
`fleet.command(agent, { id, action: 'message' | 'stop' })`. The default,
`'autonomous'`, keeps the existing goal-driven behavior.
- **`sandbox`**: Codex's sandbox for agent commands. The default stays
`danger-full-access`. `workspace-write` lets commands write only the agent's
folder plus the prepare hook's `writableRoots`, with no network; `read-only` is
also accepted.
- **`boardTools: false`** removes the FACTORY board MCP server from the sessions.
The default instructions refer to board tools, so also set `agentInstructions`
or per-agent `instructions` from the prepare hook. Verified workflows need the
board tools and automatic wakes, so they refuse either setting.
- **`prepare`** runs `argv` once per machine after the pinned clone, in the
repository folder, with `FACTORY_MACHINE_INDEX` and `FACTORY_PREPARE_SPEC` (a
JSON file holding `spec`). It prints one JSON line,
`{ "agents": [{ "cwd", "writableRoots"?, "instructions"? }] }`, with one entry per
agent slot and absolute paths. The result is kept on the volume, so a restarted
machine reuses the prepared folders without cloning again; the hook may remove
files agents must not read. Output goes to `/data/factory/prepare.log`. The
hook must be idempotent and finish within `timeoutMs` (default 30 minutes).
- **`deployKey`** clones over SSH with GitHub's published host key pinned. No `gh`
login happens and agents receive no Git credential. Use a read-only deploy key;
images without the bundle bootstrap need `openssh-client`.
- **Traces**: streaming deltas (`*Delta`, `*/delta`, rate-limit updates), the bulk
of all notifications, are no longer recorded; `item/completed` carries the same
content. `traceDeltas: true` records them again. `monitorIntervalMs` (default
15000) sets how often each machine reads operator commands.

Example models (vLLM 0.32 with `--enable-auto-tool-choice`, `--enable-prefix-caching`):

| Model | GPU | Parsers | `contextWindow` | `autoCompactTokenLimit` |
|---|---|---|---|---|
| Qwen3.8-27B FP8 | 1 × H100 80 GB | `--tool-call-parser qwen3_xml --reasoning-parser qwen3` | 262144 | 80000–100000 |
| Nemotron 3.5 Super VL 120B-A12B NVFP4 | 1 × B300 | `--tool-call-parser qwen3_coder --reasoning-parser nemotron_v3 --trust-remote-code --mamba-cache-mode align` | 262144 | 80000 |

Measured with operator-driven coding jobs: one H100 with the Qwen model kept
about 8 long conversations (histories averaging 55k tokens) at full speed; beyond that its
cache could not hold every history and prompt-cache hits fell from about 90% to
15%. One B300 with the Nemotron model carried a 190-agent run (10 machines × 19
sessions): about 170 requests at once at the peak with none queued, GPU cache at
most 37% full, 84% of 464 million input tokens served from the prompt cache, and
12.5 million output tokens in 2 h 18 min for about $20 of GPU time. Its
multi-token prediction (`--speculative-config '{"method":"mtp","num_speculative_tokens":1}'`)
makes each agent faster up to about 128 agents; beyond that run without it.
Compacting at 80000 tokens kept agent contexts between 30k and 90k.

The cloud MCP adds identity/status, work claims and progress, authenticated
messages/inbox, checkpoints, and supervisor Windows/Linux validation requests.
The operator HTTP interface supports authenticated observations, trace export,
Expand Down
40 changes: 20 additions & 20 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "flujo-factory",
"version": "0.3.0",
"version": "0.4.0",
"description": "Durable FLUJO swarm engine and Factory SDK, CLI, and MCP server",
"license": "MIT",
"repository": {
Expand Down
7 changes: 7 additions & 0 deletions scripts/verify-public-sdk.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,10 @@ assert.equal(updated.revision, 2);
assert.equal(factory.getTemplate('package-smoke').goalContext, 'Deliver a synthetic case status');
assert.equal(factory.deleteTemplate('package-smoke', { expectedRevision: updated.revision }).deleted, true);
assert.equal(factory.getTemplate('package-smoke'), null);

// Cloud lite workers accept a self-hosted model provider and operator-driven jobs.
const providerPlan = sdk.validateCloudLitePlan({ runId: 'package-smoke', app: 'package-smoke-app', goal: 'Operator jobs',
modelProvider: { id: 'selfhosted', envKey: 'SELF_HOSTED_MODEL_KEY', baseUrls: ['https://model.example/v1'], contextWindow: 262144, autoCompactTokenLimit: 80000 },
supervision: 'operator', sandbox: 'workspace-write', boardTools: false });
assert.equal(providerPlan.modelProvider.autoCompactTokenLimit, 80000);
assert.throws(() => sdk.validateCloudLitePlan({ ...providerPlan, modelProvider: { ...providerPlan.modelProvider, baseUrls: ['http://model.example/v1'] } }), TypeError);
5 changes: 3 additions & 2 deletions src/cloud-lite/bootstrap.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ mkdir -p /data/factory /data/codex /opt/factory
exec > >(tee -a /data/factory/bootstrap.log) 2>&1
echo '${bundleSha256} /tmp/factory-sdk.tgz' | sha256sum -c -
apt-get update
apt-get install -y --no-install-recommends git gh ca-certificates curl procps python3 make g++ xz-utils bubblewrap
apt-get install -y --no-install-recommends git gh openssh-client ca-certificates curl procps python3 make g++ xz-utils bubblewrap
curl -fsSL https://github.com/openai/codex/releases/download/rust-v0.162.1/codex-x86_64-unknown-linux-musl.tar.gz -o /tmp/codex.tar.gz
echo '86f268d81b898f3e144c5ecff2ad2fda2c5802e042d6195b72538fe0fa125057 /tmp/codex.tar.gz' | sha256sum -c -
tar -xzf /tmp/codex.tar.gz -C /usr/local/bin
Expand All @@ -25,7 +25,8 @@ node --input-type=module - <<'FACTORY_PIN_SOURCE'
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
const config = JSON.parse(Buffer.from(process.env.FACTORY_CLOUD_BOOTSTRAP_B64, 'base64').toString());
if (config.plan.repositoryRevision) {
// Deploy-key fleets clone and pin inside the runtime over SSH.
if (config.plan.repositoryRevision && !config.deployKey) {
if (!/^[a-f0-9]{40}$/.test(config.plan.repositoryRevision)) throw new Error('Invalid source revision.');
const cwd = '/data/repository', env = { ...process.env, GH_TOKEN: config.githubToken, GIT_TERMINAL_PROMPT: '0' };
const run = (command,args,options={}) => execFileSync(command,args,{env,stdio:['ignore','pipe','pipe'],...options});
Expand Down
15 changes: 10 additions & 5 deletions src/cloud-lite/fleet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,13 @@ export class FactoryCloudLiteFleet {
await this.stage('app.create', async () => { await this.fly(['apps', 'create', plan.app, '--org', plan.org ?? 'personal']); return { app: plan.app }; });
return this.snapshot();
}
async deploy(input, { image, imageSource, channel, githubToken, bundlePath, evidenceTokens } = {}) {
if (input.repository && !githubToken || !this.auth) throw new TypeError('Select a subscription auth broker and, for Git tasks, a GitHub credential.');
async deploy(input, { image, imageSource, channel, githubToken, bundlePath, evidenceTokens, deployKey, providerKey } = {}) {
// A self-hosted model provider replaces the subscription broker; a read-only
// SSH deploy key can replace the GitHub token for repository access.
const providerMode = Boolean(input?.modelProvider);
if (input.repository && !githubToken && !deployKey || !providerMode && !this.auth) throw new TypeError('Select a subscription auth broker (or a model provider) and, for Git tasks, a GitHub credential or deploy key.');
if (providerMode && (typeof providerKey !== 'string' || providerKey.length < 8)) throw new TypeError('A model provider needs its API key.');
if (deployKey !== undefined && (!input.repository || typeof deployKey !== 'string' || !/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(deployKey))) throw new TypeError('A deploy key must be a private SSH key for the plan repository.');
if (imageSource) image = (await (await this.upstream()).resolveWorkerImage({ source: imageSource, image, channel })).image;
if (!image) throw new TypeError('Select an immutable worker image or its upstream FLUJO compatibility advertisement.');
await this.prepare(input); const plan = this.state.plan;
Expand Down Expand Up @@ -218,9 +223,9 @@ export class FactoryCloudLiteFleet {
bootstrapPath = join(this.directory, 'bootstrap.sh'); writeFileSync(bootstrapPath, cloudLiteBootstrap(bundleDigest), { mode: 0o600 });
}
const { credentials, operatorToken } = JSON.parse(readFileSync(join(this.directory, 'credentials.json')));
const initialAuth = this.auth.read(); this.knownSecrets = [githubToken, initialAuth.accessToken, operatorToken, ...Object.values(credentials),...Object.values(evidenceTokens??{})];
const initialAuth = providerMode ? {} : this.auth.read(); this.knownSecrets = [githubToken, initialAuth.accessToken, operatorToken, deployKey, providerKey, ...Object.values(credentials),...Object.values(evidenceTokens??{})];
await this.stage('app.credentials', async () => {
const config = Buffer.from(JSON.stringify({ plan, credentials, operatorToken, initialAuth, githubToken, evidenceTokens })).toString('base64');
const config = Buffer.from(JSON.stringify({ plan, credentials, operatorToken, initialAuth, githubToken, evidenceTokens, deployKey, providerKey })).toString('base64');
this.knownSecrets.push(config);
await this.fly(['secrets', 'import', '--app', plan.app, '--stage'], { input: `FACTORY_CLOUD_BOOTSTRAP_B64=${config}\n` });
return { staged: true };
Expand Down Expand Up @@ -319,7 +324,7 @@ export class FactoryCloudLiteFleet {
// observations must never replace that controller's provisioning receipts.
const observationPath = join(this.directory, 'observation.json');
const snapshot = await this.request('/operator/status');
if (snapshot.refreshRequested) { const auth = await this.auth.refresh(); this.knownSecrets?.push(auth.accessToken); await this.request('/operator/auth', auth); }
if (snapshot.refreshRequested && this.auth) { const auth = await this.auth.refresh(); this.knownSecrets?.push(auth.accessToken); await this.request('/operator/auth', auth); }
const retained = existsSync(observationPath) ? JSON.parse(readFileSync(observationPath, 'utf8')) : null;
if (retained && retained.runId !== this.state.plan.runId) throw new Error('Observation journal belongs to another fleet.');
const observedWatermark = Number.isSafeInteger(snapshot.traceSequence) && snapshot.traceSequence >= 0 ? snapshot.traceSequence : null;
Expand Down
Loading
Loading