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
1 change: 1 addition & 0 deletions changelog.d/1502-workflow-model.added.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 22 additions & 3 deletions plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '失败策略',
Expand Down Expand Up @@ -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')}`),
Expand Down Expand Up @@ -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))),
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions plugins/workflow/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
8 changes: 4 additions & 4 deletions plugins/workflow/src/ephemeral.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion plugins/workflow/src/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@
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]),
Expand Down Expand Up @@ -266,7 +267,7 @@
const missing = unmetRequired(item, id, run.produced, run.task)
if (missing.length === 0) { started.push(id); continue }
const preds = item.edges.filter(edge => edge.to === id && edge.loop !== true).map(edge => edge.from)
const stuck = preds.length === 0 || preds.every(pred => status[pred] === 'done' || status[pred] === 'failed' || status[pred] === 'skipped')

Check warning on line 270 in plugins/workflow/src/service.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-length-check)

Found a useless array length check
if (stuck) {
status[id] = 'failed'
blocked.push({ id, missing })
Expand All @@ -279,7 +280,7 @@
function applyOp(item, run, op) {
let working = run
if (op.type === 'complete' && op.outputs && typeof op.outputs === 'object') {
working = { ...run, produced: { ...(run.produced ?? {}), [op.nodeId]: op.outputs } }

Check warning on line 283 in plugins/workflow/src/service.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
}
let result
if (op.type === 'start') result = { ...startFrontier(item, working.nodeStatus, working.fired), joined: false, layerSize: 0 }
Expand Down Expand Up @@ -378,7 +379,7 @@
push(item.id, [{ kind: 'system', tone: 'bad', text: `${node.name} 没有接到这一步(${code})。` }], now())
continue
}
current = { ...current, handed: { ...current.handed, [nodeId]: true }, inputs: { ...(current.inputs ?? {}), [nodeId]: body.inputs } }

Check warning on line 382 in plugins/workflow/src/service.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
storeRun(item.id, current)
}
write()
Expand Down Expand Up @@ -523,7 +524,7 @@
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)
Expand Down Expand Up @@ -764,6 +765,16 @@
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'
Expand Down Expand Up @@ -1069,7 +1080,7 @@
const runs = []
let endedLive = false
for (const [workflowId, lane] of Object.entries(state.lanes ?? {})) {
for (const run of [...(lane.active ?? [])]) {

Check warning on line 1083 in plugins/workflow/src/service.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
if (run?.live === true && run.status === 'running') {
failCutoffRun(workflow(workflowId), run)
archiveCutoffRun(workflowId, run)
Expand Down
36 changes: 36 additions & 0 deletions plugins/workflow/test/ephemeral.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading