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
6 changes: 3 additions & 3 deletions PUBLIC_SDK.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Public local Factory SDK, CLI, and MCP

Version 0.7.0 is distributed as a versioned package in
[GitHub Releases](https://github.com/flujo-app/FACTORY/releases/tag/v0.7.0):
Version 0.7.1 is distributed as a versioned package in
[GitHub Releases](https://github.com/flujo-app/FACTORY/releases/tag/v0.7.1):

```sh
npm install https://github.com/flujo-app/FACTORY/releases/download/v0.7.0/flujo-factory-0.7.0.tgz
npm install https://github.com/flujo-app/FACTORY/releases/download/v0.7.1/flujo-factory-0.7.1.tgz
```

FACTORY can serve an open-weight model on your own Modal GPUs and run workers on it.
Expand Down
4 changes: 2 additions & 2 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.7.0",
"version": "0.7.1",
"description": "Durable FLUJO swarm engine and Factory SDK, CLI, and MCP server",
"license": "MIT",
"repository": {
Expand Down
3 changes: 3 additions & 0 deletions src/cloud-lite/flujo-driver.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@ export class FlujoSessionDriver extends EventEmitter {
// FLUJO's shell and file tools start in its own data folder unless told otherwise.
instructions: `Your working folder is ${thread.cwd}. Pass it as cwd on every shell command and use absolute paths under it for file tools.${thread.instructions ? `\n${thread.instructions}` : ''}`,
limits: { ...(this.team.agentTurns ? { agentTurns: this.team.agentTurns } : {}), ...(this.team.leadTurns ? { leadTurns: this.team.leadTurns } : {}), ...(concurrency ? { concurrency } : {}) },
// Once a model hands off to a finish step, FLUJO keeps later messages but never shows them to the model.
// A worker's conversation takes many turns, so its flows have no such step.
finishStep: false,
environment: { workingFolder: thread.cwd } }).filter(spec => concurrency || spec.name === FACTORY_FLOW_NAMES.agent);
const digest = createHash('sha256').update(JSON.stringify(specs)).digest('hex');
// Saving a flow gives it a new identity, so an unchanged flow is left alone for the conversation that uses it.
Expand Down
22 changes: 14 additions & 8 deletions src/flujo-swarm/template/factory-team.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ function environmentText(value) {
return Object.entries(value).map(([key, entry]) => `${key}: ${entry}`).join('\n');
}

/** FLUJO subflow team template. One lead plus up to nine local agents per workspace. */
/**
* FLUJO subflow team template. One lead plus up to nine local agents per workspace.
* Without a finish step a model can only end its turn by writing its report, and the conversation can take another message.
*/
export function buildFactoryTeamSpecs({ model, availableServers = [], availableTools = {},
limits = {}, specialists, goalContext, environment, instructions } = {}) {
limits = {}, specialists, goalContext, environment, instructions, finishStep = true } = {}) {
if (typeof model !== 'string' || !model.trim() || !Array.isArray(availableServers)
|| new Set(availableServers).size !== availableServers.length
|| availableServers.some(name => typeof name !== 'string')) throw new TypeError('Model and connected server inventory are required');
Expand Down Expand Up @@ -72,22 +75,25 @@ export function buildFactoryTeamSpecs({ model, availableServers = [], availableT
`Give each admitted agent a concrete task and acceptance condition; you can also implement or independently review work yourself.${shared}\n` +
`${principles} Wait on original subflow conversation IDs for real terminal results. A queued or timed-out subflow is unfinished. ` +
`Use only the workspace and communication tools actually attached; do not infer another Worker or machine exists.${roleGuide}`;
const ending = finishStep ? '' : ' End your turn by writing your report as your final message.';
const finish = finishStep ? [{ key: 'finish', type: 'finish' }] : [];
const toFinish = from => finishStep ? [{ from, to: 'finish' }] : [];
const agent = { name: FACTORY_FLOW_NAMES.agent, description: 'General-purpose agent in a shared FLUJO workspace.',
nodes: [
{ key: 'start', type: 'start', label: 'Start', prompt: agentPrompt },
{ key: 'agent', type: 'process', label: 'Agent', model, prompt: 'Complete the assigned task and report the observed result.',
{ key: 'agent', type: 'process', label: 'Agent', model, prompt: `Complete the assigned task and report the observed result.${ending}`,
servers: selectedServers(agentServers, availableTools), maxTurns: agentTurns },
{ key: 'finish', type: 'finish' },
], edges: [{ from: 'start', to: 'agent' }, { from: 'agent', to: 'finish' }] };
...finish,
], edges: [{ from: 'start', to: 'agent' }, ...toFinish('agent')] };
const team = { name: FACTORY_FLOW_NAMES.team, description: 'General-purpose lead with a bounded native FLUJO subflow gate.',
nodes: [
{ key: 'start', type: 'start', label: 'Start', prompt: teamPrompt },
{ key: 'lead', type: 'process', label: 'Lead', model, prompt: 'Deliver and coordinate the admitted task.',
{ key: 'lead', type: 'process', label: 'Lead', model, prompt: `Deliver and coordinate the admitted task.${ending}`,
servers: selectedServers(connected, availableTools), maxTurns: leadTurns },
{ key: 'agents', type: 'subflow', label: 'agent', flow: FACTORY_FLOW_NAMES.agent,
concurrencyLimit: concurrency, inputMode: 'isolated',
prompt: 'Ask your parent for your assigned task with subflow_send_message.', outputMode: 'final-only' },
{ key: 'finish', type: 'finish' },
], edges: [{ from: 'start', to: 'lead' }, { from: 'lead', to: 'agents' }, { from: 'lead', to: 'finish' }] };
...finish,
], edges: [{ from: 'start', to: 'lead' }, { from: 'lead', to: 'agents' }, ...toFinish('lead')] };
return [agent, team];
}
4 changes: 4 additions & 0 deletions test/cloud-lite-flujo.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ test('a thread gets its own workspace with only its tools, and no secret in FLUJ
assert.deepEqual(agent.servers.map(entry => entry.name), [...FLUJO_WORKER_SERVERS, 'factory']);
assert.match(space.flows[0].spec.nodes[0].prompt, /Your working folder is .*Pass it as cwd/s);
assert.match(space.flows[0].spec.nodes[0].prompt, /Be useful\./);
assert.equal(space.flows[0].spec.nodes.some(node => node.type === 'finish'), false, 'a later message must still reach the model');
assert.match(agent.prompt, /writing your report as your final message/);
assert.equal(h.notes.some(note => note.method === 'thread/started' && note.params.threadId === thread.id), true);
} finally { await h.end(); }
});
Expand All @@ -129,6 +131,8 @@ test('an orchestrator leads a team when the plan asks for one', async () => {
assert.deepEqual(space.flows.map(flow => flow.name), ['swarm_agent', 'swarm_team']);
assert.equal(space.flows[1].spec.nodes.find(node => node.key === 'agents').concurrencyLimit, 4);
assert.equal(space.flows[1].spec.nodes.find(node => node.key === 'lead').maxTurns, 12);
assert.equal(space.flows[1].spec.nodes.some(node => node.type === 'finish'), false, 'a later message must still reach the lead');
assert.deepEqual(space.flows[1].spec.edges, [{ from: 'start', to: 'lead' }, { from: 'lead', to: 'agents' }]);
await h.driver.request('turn/start', { threadId: lead.id, input: text('lead') });
await h.driver.request('turn/start', { threadId: member.id, input: text('member') });
await waitFor(() => h.fake.count('POST', '/v1/chat/completions') === 2);
Expand Down
10 changes: 10 additions & 0 deletions test/factory-team-template.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ test('SAVIA profile builds one lead plus nine local specialist slots without fle
assert.match(specs[1].nodes[0].prompt, /independent_verifier/);
});

test('a team can leave out the finish step, and keeps it by default', () => {
const kept = buildFactoryTeamSpecs({ model: 'installed-model', availableServers: ['filesystem'] });
assert.equal(kept.every(spec => spec.nodes.some(node => node.type === 'finish')), true);
assert.equal(kept.every(spec => spec.edges.some(edge => edge.to === 'finish')), true);
const left = buildFactoryTeamSpecs({ model: 'installed-model', availableServers: ['filesystem'], finishStep: false });
assert.equal(left.some(spec => spec.nodes.some(node => node.type === 'finish')), false);
assert.equal(left.some(spec => spec.edges.some(edge => edge.to === 'finish')), false);
assert.deepEqual(left.map(spec => spec.nodes.find(node => node.type === 'process').prompt.endsWith('End your turn by writing your report as your final message.')), [true, true]);
});

test('missing required specialist tools and invalid concurrency refuse before installation', () => {
assert.throws(() => buildFactoryTeamSpecs({ model: 'installed-model',
availableServers: ['flujo'], specialists: CASE_SPECIALISTS_V1 }), /requires connected/);
Expand Down
Loading