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/);