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/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
- run: node --test test/cloud-lite.test.mjs test/cloud-lite-provider.test.mjs test/model-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
28 changes: 23 additions & 5 deletions PUBLIC_SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,13 @@ those process options. The standalone `startLiteWorker({provider,database,...})`
and `liteWorkerCommand({provider,database,...})` are also exported; the latter
only builds the process specification.

Local lite workers can also run on a self-hosted model. Pass the same provider
description the cloud plan uses (see
[Self-hosted models](#self-hosted-models-and-operator-driven-jobs)) and its key:
`startLiteWorker({ provider: 'claude', modelProvider, providerKey })`. From the
command line, `factory-lite claude board.sqlite --model-provider endpoint.json`
reads the key from the variable the file names in `envKey`.

## FLUJO worker dispatch

### Cloud Codex lite workers (development)
Expand Down Expand Up @@ -279,18 +286,29 @@ await fleet.deploy({
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
- **`modelProvider`** is one description of the endpoint that each worker program
maps to its own settings. `id`, `envKey` (the variable that carries the key) and
`https` `baseUrls` (OpenAI-style, ending in `/v1`) without credentials are
required. `apis` lists the wire APIs the server offers: `responses` (Codex),
`anthropic` (Claude Code, `/v1/messages`) and `chat`. vLLM serves all three; the
default is `['responses']`. `name`, `model`, `contextWindow`,
`autoCompactTokenLimit`, `toolOutputTokenLimit`, `requestMaxRetries`,
`streamMaxRetries`, `streamIdleTimeoutMs` and `requestTimeoutMs` are optional.
`codexProviderConfig`, `codexProviderOverrides` and `claudeProviderEnvironment`
are exported mappings; `providerPrograms(provider)` says which programs can use
it. Cloud lite workers run Codex. 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.
Claude Code gets the server root as `ANTHROPIC_BASE_URL`, the key as a bearer
token, every model alias on the served model, the real context window and
compaction point, and no telemetry or per-turn token reminder. That reminder
would be an extra system message that templates move to the front of the
prompt, which defeats the server's prompt cache.
- **`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,
Expand Down
22 changes: 19 additions & 3 deletions bin/factory-lite.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { Factory } from '../src/public-sdk.mjs';

const [provider, database, ...words] = process.argv.slice(2);
const usage = 'Usage: factory-lite <codex|claude> <database-path> [--model-provider <endpoint.json>] [prompt]\n'
+ ' --model-provider use a self-hosted model (e.g. from `factory-selfhost endpoint`); its key is read from the\n'
+ ' environment variable the file names in envKey.\n';
const args = process.argv.slice(2);
const [provider, database] = args;
if (provider === '--help' || provider === 'help' || !provider) {
process.stdout.write('Usage: factory-lite <codex|claude> <database-path> [prompt]\n');
process.stdout.write(usage);
} else {
try {
const child = new Factory(database).startLiteWorker({ provider, prompt: words.length ? words.join(' ') : undefined });
const words = args.slice(2);
let modelProvider, providerKey;
const flag = words.indexOf('--model-provider');
if (flag !== -1) {
const file = words[flag + 1];
if (!file) throw new Error('--model-provider needs an endpoint description file.');
words.splice(flag, 2);
modelProvider = JSON.parse(readFileSync(file, 'utf8'));
providerKey = process.env[modelProvider.envKey];
if (!providerKey) throw new Error(`Set ${modelProvider.envKey} to the endpoint key.`);
}
const child = new Factory(database).startLiteWorker({ provider, prompt: words.length ? words.join(' ') : undefined, modelProvider, providerKey });
child.on('error', error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
child.on('exit', (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); });
} catch (error) {
Expand Down
5 changes: 5 additions & 0 deletions scripts/verify-public-sdk.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,8 @@ const providerPlan = sdk.validateCloudLitePlan({ runId: 'package-smoke', app: 'p
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);
// One provider description maps to Codex and Claude Code.
const endpoint = { ...providerPlan.modelProvider, model: 'served-model', apis: ['responses', 'anthropic'] };
assert.deepEqual(sdk.providerPrograms(endpoint), ['codex', 'claude']);
assert.match(sdk.codexProviderConfig(endpoint), /wire_api = "responses"/);
assert.equal(sdk.claudeProviderEnvironment(endpoint, { key: 'package-smoke-key' }).ANTHROPIC_BASE_URL, 'https://model.example');
33 changes: 8 additions & 25 deletions src/cloud-lite/protocol.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { createHash, randomBytes } from 'node:crypto';
import { validateModelProvider, providerApis } from '../model-provider.mjs';

export { validateModelProvider };

export const secret = () => randomBytes(32).toString('hex');
export const hash = value => createHash('sha256').update(typeof value === 'string' || Buffer.isBuffer(value) ? value : JSON.stringify(value)).digest('hex');
Expand Down Expand Up @@ -26,7 +29,11 @@ export function validateCloudLitePlan(input) {
if(plan.shutdown){
if(!plan.collection || typeof plan.shutdown.deadlineUtc!=='string' || !Number.isFinite(Date.parse(plan.shutdown.deadlineUtc)))throw new TypeError('Automatic deadline shutdown requires evidence collection and an explicit UTC deadline.');
}
if (plan.modelProvider !== undefined) validateModelProvider(plan.modelProvider);
if (plan.modelProvider !== undefined) {
validateModelProvider(plan.modelProvider);
// Cloud lite workers run Codex, which talks to custom providers through the Responses API.
if (!providerApis(plan.modelProvider).includes('responses')) throw new TypeError('Cloud lite workers need a provider that offers the responses API.');
}
if (plan.supervision !== undefined && !['autonomous', 'operator'].includes(plan.supervision)) throw new TypeError('supervision must be autonomous or operator.');
if (plan.sandbox !== undefined && !CODEX_SANDBOXES.includes(plan.sandbox)) throw new TypeError(`sandbox must be one of ${CODEX_SANDBOXES.join(', ')}.`);
for (const name of ['boardTools', 'traceDeltas']) if (plan[name] !== undefined && typeof plan[name] !== 'boolean') throw new TypeError(`${name} must be true or false.`);
Expand All @@ -43,30 +50,6 @@ export function validateCloudLitePlan(input) {
return plan;
}
export const CODEX_SANDBOXES = ['danger-full-access', 'workspace-write', 'read-only'];
// Variables the runtime sets for the Codex process itself; a provider key must not replace them.
const RESERVED_ENVIRONMENT = new Set(['PATH', 'HOME', 'CODEX_HOME', 'GH_TOKEN', 'GIT_TERMINAL_PROMPT']);
const credentialFreeHttps = value => {
try { const url = new URL(value); return url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash; }
catch { return false; }
};
/** A self-hosted OpenAI-compatible (Responses API) model provider for the Codex workers. */
export function validateModelProvider(provider) {
if (!provider || typeof provider !== 'object' || Array.isArray(provider)) throw new TypeError('modelProvider must be an object.');
if (!/^[a-z][a-z0-9_-]{1,40}$/.test(provider.id ?? '')) throw new TypeError('modelProvider.id must be a short lowercase name.');
if (!/^[A-Z][A-Z0-9_]{2,60}$/.test(provider.envKey ?? '') || RESERVED_ENVIRONMENT.has(provider.envKey)) throw new TypeError('modelProvider.envKey must name a dedicated environment variable.');
if (!Array.isArray(provider.baseUrls) || !provider.baseUrls.length || provider.baseUrls.length > 64 || !provider.baseUrls.every(credentialFreeHttps))
throw new TypeError('modelProvider.baseUrls must list https URLs without credentials, query or fragment.');
for (const name of ['name', 'model']) if (provider[name] !== undefined && (typeof provider[name] !== 'string' || !provider[name].trim() || provider[name].length > 200)) throw new TypeError(`modelProvider.${name} must be short text.`);
for (const name of ['contextWindow', 'autoCompactTokenLimit', 'toolOutputTokenLimit']) {
if (provider[name] !== undefined && (!Number.isSafeInteger(provider[name]) || provider[name] < 1000)) throw new TypeError(`modelProvider.${name} must be a token count of at least 1000.`);
}
if (provider.contextWindow && provider.autoCompactTokenLimit && provider.autoCompactTokenLimit >= provider.contextWindow) throw new TypeError('modelProvider.autoCompactTokenLimit must be below contextWindow.');
for (const name of ['requestMaxRetries', 'streamMaxRetries']) {
if (provider[name] !== undefined && (!Number.isSafeInteger(provider[name]) || provider[name] < 0 || provider[name] > 100)) throw new TypeError(`modelProvider.${name} must be between 0 and 100.`);
}
if (provider.streamIdleTimeoutMs !== undefined && (!Number.isSafeInteger(provider.streamIdleTimeoutMs) || provider.streamIdleTimeoutMs < 1000)) throw new TypeError('modelProvider.streamIdleTimeoutMs must be at least 1000.');
return provider;
}
export function agentInventory(plan) {
return Array.from({ length: plan.machines }, (_, machine) => Array.from({ length: plan.agentsPerMachine }, (_, agent) => ({
id: `m${machine}-a${agent}`, machine: `m${machine}`, role: agent === 0 ? 'orchestrator' : 'subagent',
Expand Down
43 changes: 3 additions & 40 deletions src/cloud-lite/runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { CloudLiteTrace } from './trace.mjs';
import { NativeOperatorStop } from './operator-stop.mjs';
import { NativeWorkflowWake } from './workflow-wake.mjs';
import { hash } from './protocol.mjs';
import { codexProviderConfig } from '../model-provider.mjs';

const threadStatuses = new Set(['active','idle','archived','notLoaded','systemError','notFound']);
const turnStatuses = new Set(['inProgress','completed','interrupted','failed']);
Expand Down Expand Up @@ -401,47 +402,9 @@ export function nativeAgentConfig({ plan, slot, directory, board }) {
return { sandbox, config };
}

const tomlString = value => JSON.stringify(String(value));

/** Codex configuration for a self-hosted OpenAI-compatible (Responses API) model provider. */
/** Codex configuration for the plan's self-hosted provider on machine `index`. */
export function providerConfigToml(plan, index) {
const p = plan.modelProvider;
return [
`model = ${tomlString(p.model ?? plan.model)}`,
`model_provider = ${tomlString(p.id)}`,
`model_reasoning_effort = ${tomlString(plan.reasoningEffort)}`,
...(p.contextWindow ? [`model_context_window = ${Number(p.contextWindow)}`] : []),
...(p.autoCompactTokenLimit ? [`model_auto_compact_token_limit = ${Number(p.autoCompactTokenLimit)}`] : []),
...(p.toolOutputTokenLimit ? [`tool_output_token_limit = ${Number(p.toolOutputTokenLimit)}`] : []),
'web_search = "disabled"',
'check_for_update_on_startup = false',
'cli_auth_credentials_store = "ephemeral"',
'',
'[features]',
'multi_agent = false',
'goals = false',
'apps = false',
'plugins = false',
'memories = false',
'',
'[skills]',
'include_instructions = false',
'',
// Agents' shell commands get only the basic variables, never the provider key.
'[shell_environment_policy]',
'inherit = "core"',
'',
`[model_providers.${p.id}]`,
`name = ${tomlString(p.name ?? p.id)}`,
// Machines are spread over the endpoints, so each keeps its prompt cache on one server.
`base_url = ${tomlString(p.baseUrls[index % p.baseUrls.length])}`,
`env_key = ${tomlString(p.envKey)}`,
'wire_api = "responses"',
`request_max_retries = ${Number(p.requestMaxRetries ?? 8)}`,
`stream_max_retries = ${Number(p.streamMaxRetries ?? 10)}`,
`stream_idle_timeout_ms = ${Number(p.streamIdleTimeoutMs ?? 900000)}`,
'',
].join('\n');
return codexProviderConfig(plan.modelProvider, { index, model: plan.model, reasoningEffort: plan.reasoningEffort });
}

/**
Expand Down
27 changes: 22 additions & 5 deletions src/lite-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,43 @@ import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { spawn } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import { applyProviderEnvironment, claudeProviderEnvironment, codexProviderOverrides, validateModelProvider } from './model-provider.mjs';

const mcp = fileURLToPath(new URL('../bin/factory-mcp.mjs', import.meta.url));

/** Build a normal provider CLI command with its native tools and only FACTORY's MCP server. */
export function liteWorkerCommand({ provider, database, prompt, agentId, cwd = process.cwd() } = {}) {
/**
* Build a normal provider CLI command with its native tools and only FACTORY's MCP server.
* With `modelProvider` (and its `providerKey`) the program talks to a self-hosted model
* instead of its vendor login: Codex through the Responses API, Claude Code through the
* Anthropic Messages API. `providerIndex` picks one of several endpoints.
*/
export function liteWorkerCommand({ provider, database, prompt, agentId, cwd = process.cwd(), modelProvider, providerKey, providerIndex = 0, env = process.env } = {}) {
if (!['codex', 'claude'].includes(provider)) throw new TypeError('provider must be codex or claude');
if (typeof database !== 'string' || !database.trim()) throw new TypeError('database path is required');
if (prompt !== undefined && (typeof prompt !== 'string' || !prompt.trim())) throw new TypeError('prompt must be nonempty');
const db = resolve(database);
if(agentId!==undefined && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(agentId)) throw new TypeError('Invalid bound agent identity.');
if (modelProvider !== undefined) {
validateModelProvider(modelProvider);
if (typeof providerKey !== 'string' || providerKey.length < 8) throw new TypeError('A model provider needs its API key.');
if (!Number.isSafeInteger(providerIndex) || providerIndex < 0) throw new TypeError('providerIndex must be a nonnegative integer.');
}
const server = { command: process.execPath, args: [mcp, db, ...(agentId ? ['--actor',agentId] : [])] };
if (provider === 'claude') return {
command: 'claude', cwd, args: ['--dangerously-skip-permissions', '--strict-mcp-config',
command: 'claude', cwd,
...(modelProvider ? { env: applyProviderEnvironment(env, claudeProviderEnvironment(modelProvider, { index: providerIndex, key: providerKey })) } : {}),
args: ['--dangerously-skip-permissions', '--strict-mcp-config',
'--mcp-config', JSON.stringify({ mcpServers: { factory: server } }), ...(prompt === undefined ? [] : [prompt])],
};
const toml = value => JSON.stringify(value);
const config = `mcp_servers={factory={command=${toml(server.command)},args=[${server.args.map(toml).join(',')}]}}`;
return { command: 'codex', cwd, env: { ...process.env, CODEX_HOME: resolve(homedir(), '.factory', 'lite-codex') },
// A provider gets its own Codex home, so no vendor login or earlier settings apply.
const home = resolve(homedir(), '.factory', modelProvider ? `lite-codex-${modelProvider.id}` : 'lite-codex');
return { command: 'codex', cwd, env: { ...env, CODEX_HOME: home, ...(modelProvider ? { [modelProvider.envKey]: providerKey } : {}) },
args: ['--sandbox', 'danger-full-access', '--ask-for-approval', 'never',
'--config', config, ...(prompt === undefined ? [] : [prompt])] };
'--config', config,
...(modelProvider ? codexProviderOverrides(modelProvider, { index: providerIndex }).flatMap(entry => ['--config', entry]) : []),
...(prompt === undefined ? [] : [prompt])] };
}

/** Launch an interactive native CLI session and return its ChildProcess. */
Expand Down
Loading
Loading