From 5601cb85cfc40e3899fb41045d1532b961d73545 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:32:26 -0500 Subject: [PATCH] FLUJO workers: a later message reaches the model; release 0.7.1 A model that handed off to the flow's finish step left the conversation there. FLUJO kept every later message but never showed it to the model, and still reported the turn as completed. The handoff could also end a turn with no written report. Worker flows now have no finish step, so a model ends its turn by writing its report and the conversation can take another message. --- PUBLIC_SDK.md | 6 +++--- package-lock.json | 4 ++-- package.json | 2 +- src/cloud-lite/flujo-driver.mjs | 3 +++ src/flujo-swarm/template/factory-team.mjs | 22 ++++++++++++++-------- test/cloud-lite-flujo.test.mjs | 4 ++++ test/factory-team-template.test.mjs | 10 ++++++++++ 7 files changed, 37 insertions(+), 14 deletions(-) diff --git a/PUBLIC_SDK.md b/PUBLIC_SDK.md index 135734a..43dd5de 100644 --- a/PUBLIC_SDK.md +++ b/PUBLIC_SDK.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 6eb85b8..2c53c38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "flujo-factory", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flujo-factory", - "version": "0.7.0", + "version": "0.7.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.32.1" diff --git a/package.json b/package.json index 1dc7466..0792fdd 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/cloud-lite/flujo-driver.mjs b/src/cloud-lite/flujo-driver.mjs index 3abbdd4..d4b3635 100644 --- a/src/cloud-lite/flujo-driver.mjs +++ b/src/cloud-lite/flujo-driver.mjs @@ -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. diff --git a/src/flujo-swarm/template/factory-team.mjs b/src/flujo-swarm/template/factory-team.mjs index b2ecaca..c2ad9ba 100644 --- a/src/flujo-swarm/template/factory-team.mjs +++ b/src/flujo-swarm/template/factory-team.mjs @@ -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'); @@ -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]; } diff --git a/test/cloud-lite-flujo.test.mjs b/test/cloud-lite-flujo.test.mjs index 9ea1558..a732059 100644 --- a/test/cloud-lite-flujo.test.mjs +++ b/test/cloud-lite-flujo.test.mjs @@ -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(); } }); @@ -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); diff --git a/test/factory-team-template.test.mjs b/test/factory-team-template.test.mjs index b74bebd..b6b9d79 100644 --- a/test/factory-team-template.test.mjs +++ b/test/factory-team-template.test.mjs @@ -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/);