diff --git a/changelog.d/1502-workflow-model.added.md b/changelog.d/1502-workflow-model.added.md new file mode 100644 index 000000000..bc3004a34 --- /dev/null +++ b/changelog.d/1502-workflow-model.added.md @@ -0,0 +1 @@ +Workflow graphs now support choosing a model for the whole Workflow. Settings → Workflow graphs and the Details panel show the chosen model; when set, every ephemeral Bot in a run uses it instead of the seated Bot's model. diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 9c22aa64a..7e678c82e 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -61,6 +61,7 @@ window.__ModuleLoader__.load({ Schedule: '定时', Model: '模型', 'Each Bot\'s own model': '跟随各个 Bot 的模型', + '{count} models': '{count} 个模型', Permissions: '权限', 'Each Bot\'s own permission': '跟随各个 Bot 的权限', 'On failure': '失败策略', @@ -447,7 +448,7 @@ window.__ModuleLoader__.load({ key, type: 'button', className: tab === key ? 'on' : '', onClick: () => onTab(key), }, tr(key === 'flow' ? 'Flow' : key === 'details' ? 'Details' : 'Runs')))), tab === 'flow' ? h(Flow, { workflow, run, roster, onTip, editing, onCommit }) : null, - tab === 'details' ? h(Details, { workflow }) : null, + tab === 'details' ? h(Details, { workflow, roster }) : null, tab === 'runs' ? h(Runs, { workflow }) : null, h('div', { className: 'wf-ctl' }, h('button', { type: 'button', className: 'wf-btn', onClick: onReset }, `↺ ${tr('Reset')}`), @@ -672,14 +673,17 @@ window.__ModuleLoader__.load({ h('span', { className: 'wf-rl' }, node.role))) } - function Details({ workflow }) { + function Details({ workflow, roster }) { const trigger = workflow.trigger === 'schedule' ? `${tr('Schedule')} · ${workflow.scheduleLabel}` : tr('Manual') const retries = workflow.nodes.some(node => node.failOnce) + const modelLabel = workflow.model + ? (roster?.models ?? []).find(m => m.ref === workflow.model)?.name ?? workflow.model + : tr('Each Bot\'s own model') return h('div', { className: 'wf-box' }, h('div', { className: 'wf-kv' }, h('span', null, tr('Trigger')), h('span', null, trigger)), - h('div', { className: 'wf-kv' }, h('span', null, tr('Model')), h('span', null, tr('Each Bot\'s own model'))), + h('div', { className: 'wf-kv' }, h('span', null, tr('Model')), h('span', null, modelLabel)), h('div', { className: 'wf-kv' }, h('span', null, tr('Permissions')), h('span', null, tr('Each Bot\'s own permission'))), h('div', { className: 'wf-kv' }, h('span', null, tr('On failure')), h('span', null, tr(retries ? 'Tester retries once. Any other failed step stops the run.' : 'A failed step stops the run.'))), h('div', { className: 'wf-kv' }, h('span', null, tr('Concurrent runs')), h('span', null, String(workflow.maxConcurrent ?? 2))), @@ -886,6 +890,21 @@ window.__ModuleLoader__.load({ value: current.id, onChange: (event) => { setWorkflowId(event.target.value); setSelectedNode(null) }, }, workflows.map(item => h('option', { key: item.id, value: item.id }, item.name)))), + h('label', { className: 'wf-field' }, + h('span', null, tr('Model')), + h('select', { + 'aria-label': tr('Model'), + value: current.model || '', + onChange: (event) => { + const model = event.target.value || null + void Promise.resolve(call('workflow/set-model', { workflowId: current.id, model })).then(value => { + if (value?.ok === false) { setError(value.error ?? ''); return } + if (value?.workflows) setPack(value) + }) + }, + }, + h('option', { value: '' }, tr('Each Bot\'s own model')), + (pack?.roster?.models ?? []).map(model => h('option', { key: model.ref, value: model.ref }, `${model.name} (${model.provider})`)))), h('button', { type: 'button', className: 'wf-btn', onClick: () => setEditing(!editing) }, editing ? tr('Finish editing') : tr('Edit')), editing ? h('span', { className: 'wf-autosave-hint' }, tr('Changes are saved automatically')) : null), diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js index 195046a43..b54436512 100644 --- a/plugins/workflow/index.js +++ b/plugins/workflow/index.js @@ -98,6 +98,7 @@ export function apply(ctx, config = {}) { fb.api.handle('workflow/delete', body => service.delete(body)), fb.api.handle('workflow/assign', body => service.assign(body)), fb.api.handle('workflow/concurrency', body => service.setConcurrency(body)), + fb.api.handle('workflow/set-model', body => service.setModel(body)), fb.api.handle('workflow/advance', body => service.advance(body)), fb.api.handle('workflow/reset', body => service.reset(body)), fb.api.handle('workflow/pause', body => service.pause(body)), diff --git a/plugins/workflow/src/ephemeral.js b/plugins/workflow/src/ephemeral.js index 4a1b7b2cb..d747ef76a 100644 --- a/plugins/workflow/src/ephemeral.js +++ b/plugins/workflow/src/ephemeral.js @@ -13,7 +13,7 @@ export function permissionFor(tools) { return 'workspace-write' } -export function nodeConfig(node, roster = []) { +export function nodeConfig(node, roster = [], workflowModel = '') { const seated = node?.botId ? roster.find(bot => bot && bot.id === node.botId && bot.remote !== true) : undefined @@ -25,19 +25,19 @@ export function nodeConfig(node, roster = []) { name: node.name, role: cleanLine(node.role || seated?.role, 80), prompt, - model: cleanLine(node.model || seated?.model, 80), + model: cleanLine(workflowModel || node.model || seated?.model, 80), tools: cleanTools(node.tools), hidden: true, ephemeral: true, } } -export async function spawnAll(nodes, roster, spawn) { +export async function spawnAll(nodes, roster, spawn, workflowModel = '') { const made = [] try { const ephemeral = {} for (const node of nodes) { - const spec = nodeConfig(node, roster) + const spec = nodeConfig(node, roster, workflowModel) const id = await spawn(spec) if (typeof id !== 'string' || id === '') throw new Error('The run could not start a Bot') made.push(id) diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js index 52ec6d5f9..656ffa9e4 100644 --- a/plugins/workflow/src/service.js +++ b/plugins/workflow/src/service.js @@ -169,6 +169,7 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no description: item.description ?? '', trigger: item.trigger ?? 'manual', scheduleLabel: item.scheduleLabel ?? '', + model: item.model ?? '', nodes: item.nodes.map(node => publicNode(node, roster)), edges: (item.edges ?? []).map(publicEdge), layers: item.layers.map(layer => [...layer]), @@ -523,7 +524,7 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no const holes = missingProviders(item) if (holes.length > 0) return { ok: false, error: `Node ${holes[0].nodeId} input ${holes[0].name} has no upstream provider` } try { - ephemeral = await spawnAll(item.nodes, bots() ?? [], spawn) + ephemeral = await spawnAll(item.nodes, bots() ?? [], spawn, item.model ?? '') } catch (error) { await releaseAll(error.spawned ?? [], release, warn) return refuse(error) @@ -764,6 +765,16 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no return present() }) }, + setModel(body = {}) { + if (readOnly()) return { ok: false, code: 'read-only', error: 'The host is read-only' } + const workflowId = String(body.workflowId ?? '') + const model = body.model ? cleanLine(body.model, 80) : null + const saved = mutate(workflowId, (item) => { + item.model = model + }) + if (saved.ok === false) return saved + return present() + }, advance(body = {}) { const workflowId = String(body.workflowId ?? '') const outcome = body.outcome === 'failed' ? 'fail' : 'complete' diff --git a/plugins/workflow/test/ephemeral.test.js b/plugins/workflow/test/ephemeral.test.js index c5413a40f..d6bbeabfb 100644 --- a/plugins/workflow/test/ephemeral.test.js +++ b/plugins/workflow/test/ephemeral.test.js @@ -72,6 +72,42 @@ test('define_workflow stores a node prompt, and the graph save edits it', () => assert.equal(edited.workflows.find(item => item.id === made.id).nodes.find(node => node.id === 'code').prompt, 'Keep the diff small.') }) +test('workflow-level model is passed to all ephemeral Bots when set', async () => { + const { svc, spawned } = harness() + const made = svc.define({ + name: 'Workflow with model', + nodes: [ + { id: 'plan', name: 'Planner', role: 'plan', prompt: 'Plan it.' }, + { id: 'code', name: 'Coder', role: 'code', prompt: 'Code it.', model: 'mock/coder-override' }, + ], + edges: [{ from: 'plan', to: 'code' }], + }).workflows.find(item => item.name === 'Workflow with model') + + const withModel = await svc.setModel({ workflowId: made.id, model: 'mock/workflow-model' }) + assert.equal(withModel.ok, true) + const updated = withModel.workflows.find(item => item.id === made.id) + assert.equal(updated.model, 'mock/workflow-model') + + await svc.runNamed({ workflow: 'Workflow with model', brief: 'Test workflow model' }) + assert.equal(spawned.length, 2) + const planSpec = spawned.find(item => item.spec.nodeId === 'plan').spec + const codeSpec = spawned.find(item => item.spec.nodeId === 'code').spec + assert.equal(planSpec.model, 'mock/workflow-model') + assert.equal(codeSpec.model, 'mock/workflow-model') +}) + +test('workflow model is empty by default and uses seated Bot model', async () => { + const { svc, spawned } = harness() + const made = lane(svc).workflows.find(item => item.name === 'Lane') + assert.equal(made.model, '') + + await svc.runNamed({ workflow: 'Lane', brief: 'Test default' }) + const planSpec = spawned.find(item => item.spec.nodeId === 'plan').spec + assert.equal(planSpec.model, '') + const codeSpec = spawned.find(item => item.spec.nodeId === 'code').spec + assert.equal(codeSpec.model, 'mock/coder') +}) + test('a run spawns a hidden Bot per node, hands it the step, and removes every one when the run finishes', async () => { const { svc, spawned, released, dispatched } = harness() const made = lane(svc).workflows.find(item => item.name === 'Lane')