mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-10 12:23:17 +08:00
Allow each scheduled job to pin its provider and model.
New jobs snapshot the current New Session default so quota is predictable. cron_create inherits the calling session model unless provider+model are set. The editor lists configured models; the job row shows which model will run.
This commit is contained in:
parent
ef8ff6e10d
commit
b0d0db1553
11 changed files with 353 additions and 19 deletions
|
|
@ -80,6 +80,22 @@ v1 用 `workspaceRegistry.archiveSession` 从工作区列表拿掉。归档当
|
|||
|
||||
---
|
||||
|
||||
## 每个任务要能指定模型
|
||||
|
||||
到点跑的会话会打对应厂商的额度。如果只用「当时新会话默认」,用户不知道扣的是哪家,聊天里一换模型,定时任务也跟着变。
|
||||
|
||||
定下来:
|
||||
|
||||
- 任务可存 `provider` + `model`(可选 `reasoningEffort`)
|
||||
- 编辑器下拉已配置的模型;也可选「每次运行用当时的新会话默认」
|
||||
- 新建时默认钉死**当前**新会话模型,避免空白
|
||||
- `cron_create` 没传就快照当前会话模型
|
||||
- 列表标题旁显示模型名
|
||||
|
||||
空 provider/model 才在 fire 时读 `agentDefaultModel`。
|
||||
|
||||
---
|
||||
|
||||
## 执行失败 `{{model}}` 没有值
|
||||
|
||||
`agents.create` 不自动带上「新会话」用的默认模型。Persona 模板插 `{{model}}`,空值会让整轮装配失败。
|
||||
|
|
|
|||
|
|
@ -295,6 +295,9 @@ window.__ModuleLoader__.load({
|
|||
cwdRecent: '最近使用的工作区', cwdCustom: '自定义路径…',
|
||||
cwdPlaceholder: '/absolute/path',
|
||||
cwdHint: '运行会在这个目录对应的工作区里开新会话。留空则用最近工作区。',
|
||||
model: '模型',
|
||||
modelDefault: '每次运行用当时的新会话默认',
|
||||
modelHint: '定时任务会消耗这个模型的额度。指定后不会跟着聊天模型变。',
|
||||
editorLead: '保存后按日程在新会话里执行。点击下方某次运行,右侧会打开那次对话,可以继续。',
|
||||
lastOutput: '上次输出', noOutput: '还没有输出。先立即运行一次。',
|
||||
}
|
||||
|
|
@ -319,6 +322,9 @@ window.__ModuleLoader__.load({
|
|||
cwdRecent: 'Most recent workspace', cwdCustom: 'Custom path…',
|
||||
cwdPlaceholder: '/absolute/path',
|
||||
cwdHint: 'Runs start a session in this workspace folder. Leave empty to use the most recent workspace.',
|
||||
model: 'Model',
|
||||
modelDefault: 'Use the New Session default at fire time',
|
||||
modelHint: 'Scheduled runs consume this model\'s quota. A pinned model will not follow the chat selector.',
|
||||
editorLead: 'Runs start a new session. Click a run below to open that conversation and continue it.',
|
||||
lastOutput: 'Last output', noOutput: 'No output yet. Run it once.',
|
||||
}
|
||||
|
|
@ -557,10 +563,12 @@ window.__ModuleLoader__.load({
|
|||
return ms == null ? raw : new Date(ms).toISOString()
|
||||
}
|
||||
|
||||
function emptyForm(timezone) {
|
||||
function emptyForm(timezone, current) {
|
||||
return {
|
||||
name: '', prompt: '', kind: 'cron', expr: '0 9 * * 1-5', at: '',
|
||||
timezone: timezone || DEFAULTS.timezone, cwd: '', timeoutMinutes: 10, enabled: true,
|
||||
provider: current?.provider || '',
|
||||
model: current?.model || '',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -575,9 +583,28 @@ window.__ModuleLoader__.load({
|
|||
cwd: job.cwd || '',
|
||||
timeoutMinutes: job.timeoutMinutes || 10,
|
||||
enabled: job.enabled !== false,
|
||||
provider: job.provider || '',
|
||||
model: job.model || '',
|
||||
}
|
||||
}
|
||||
|
||||
function modelKey(provider, model) {
|
||||
if (!provider || !model) return ''
|
||||
return `${provider}::${model}`
|
||||
}
|
||||
|
||||
function parseModelKey(value) {
|
||||
const raw = String(value || '')
|
||||
const at = raw.indexOf('::')
|
||||
if (at <= 0) return { provider: '', model: '' }
|
||||
return { provider: raw.slice(0, at), model: raw.slice(at + 2) }
|
||||
}
|
||||
|
||||
function jobModelLabel(job) {
|
||||
if (job?.provider && job?.model) return job.model
|
||||
return ''
|
||||
}
|
||||
|
||||
function JobList({ t, jobs, runs, selection, expanded, onSelectJob, onSelectRun, onNew, onRun, onToggle, onRemove, onToggleGroup }) {
|
||||
const skin = workspaceSkin()
|
||||
const [query, setQuery] = useState('')
|
||||
|
|
@ -590,6 +617,8 @@ window.__ModuleLoader__.load({
|
|||
job.prompt,
|
||||
job.schedule?.expr,
|
||||
job.schedule?.at,
|
||||
job.provider,
|
||||
job.model,
|
||||
...(runs.filter((run) => run.jobId === job.id).map((run) => run.summary || run.status)),
|
||||
].join(' ').toLowerCase()
|
||||
return hay.includes(needle)
|
||||
|
|
@ -676,7 +705,9 @@ window.__ModuleLoader__.load({
|
|||
h('span', { className: joinClass(skin.arrow, open ? skin.arrowOpen : '') },
|
||||
chevronIcon() || '▸')),
|
||||
h('span', { className: skin.projectText },
|
||||
h('span', { className: skin.title }, paused ? `${job.name} · ${t('paused')}` : job.name)),
|
||||
h('span', { className: skin.title }, paused
|
||||
? `${job.name} · ${t('paused')}`
|
||||
: (jobModelLabel(job) ? `${job.name} · ${jobModelLabel(job)}` : job.name))),
|
||||
h('span', { className: skin.rowActs, onClick: (e) => e.stopPropagation() },
|
||||
h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) },
|
||||
playIcon() || '▶'),
|
||||
|
|
@ -760,7 +791,33 @@ window.__ModuleLoader__.load({
|
|||
)
|
||||
}
|
||||
|
||||
function JobEditor({ t, form, setForm, isNew, error, lastOutput, nextRunAt, lastRunAt, workspaces, onSave, onRun, onPause, onRemove }) {
|
||||
function ModelField({ t, form, setForm, catalog }) {
|
||||
const groups = Array.isArray(catalog?.groups) ? catalog.groups : []
|
||||
const current = catalog?.current
|
||||
const selected = modelKey(form.provider, form.model)
|
||||
const known = groups.some((group) => (group.models || []).some((entry) => modelKey(group.provider, entry.id) === selected))
|
||||
const defaultLabel = current?.model
|
||||
? `${t('modelDefault')}(${current.model})`
|
||||
: t('modelDefault')
|
||||
return h('label', null, t('model'),
|
||||
h('select', {
|
||||
value: selected,
|
||||
onChange: (e) => setForm({ ...form, ...parseModelKey(e.target.value) }),
|
||||
},
|
||||
h('option', { value: '' }, defaultLabel),
|
||||
!known && selected
|
||||
? h('option', { value: selected }, `${form.model} · ${form.provider}`)
|
||||
: null,
|
||||
...groups.flatMap((group) => (group.models || []).map((entry) => h('option', {
|
||||
value: modelKey(group.provider, entry.id),
|
||||
key: modelKey(group.provider, entry.id),
|
||||
}, `${entry.name || entry.id} · ${group.displayName || group.provider}`))),
|
||||
),
|
||||
h('span', { className: 'dsh-ct-cwdHint' }, t('modelHint')),
|
||||
)
|
||||
}
|
||||
|
||||
function JobEditor({ t, form, setForm, isNew, error, lastOutput, nextRunAt, lastRunAt, workspaces, catalog, onSave, onRun, onPause, onRemove }) {
|
||||
return h('div', { className: 'dsh-ct-editor' },
|
||||
h('h1', null, isNew ? t('newJob') : (form.name || t('title'))),
|
||||
h('p', { className: 'dsh-ct-editorLead' }, t('editorLead')),
|
||||
|
|
@ -791,6 +848,7 @@ window.__ModuleLoader__.load({
|
|||
h('label', null, t('timeout'), h('input', { type: 'number', min: 1, max: 240, value: form.timeoutMinutes, onChange: (e) => setForm({ ...form, timeoutMinutes: Number(e.target.value) }) })),
|
||||
),
|
||||
h(CwdField, { t, form, setForm, workspaces }),
|
||||
h(ModelField, { t, form, setForm, catalog }),
|
||||
h('div', { className: 'dsh-ct-editorActs' },
|
||||
h('button', { type: 'button', className: 'dsh-ct-primary', onClick: onSave }, t('save')),
|
||||
!isNew ? h('button', { type: 'button', className: 'dsh-ct-secondary', onClick: onRun }, t('runNow')) : null,
|
||||
|
|
@ -813,18 +871,21 @@ window.__ModuleLoader__.load({
|
|||
const [error, setError] = useState('')
|
||||
const [cronMode, setCronMode] = useState(false)
|
||||
const [workspaces, setWorkspaces] = useState([])
|
||||
const [catalog, setCatalog] = useState({ groups: [], current: null })
|
||||
const skipAutoSelect = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [jobBody, histBody, wsBody] = await Promise.all([
|
||||
const [jobBody, histBody, wsBody, modelBody] = await Promise.all([
|
||||
api('/jobs'),
|
||||
api('/history'),
|
||||
api('/workspaces').catch(() => ({ workspaces: [] })),
|
||||
api('/models').catch(() => ({ groups: [], current: null })),
|
||||
])
|
||||
setJobs(jobBody.jobs || [])
|
||||
setRuns(histBody.runs || [])
|
||||
setWorkspaces(wsBody.workspaces || [])
|
||||
setCatalog({ groups: modelBody.groups || [], current: modelBody.current || null })
|
||||
faces.cronSessionIds = new Set((histBody.runs || []).map((run) => run.sessionId).filter(Boolean))
|
||||
setError('')
|
||||
} catch (err) {
|
||||
|
|
@ -874,11 +935,17 @@ window.__ModuleLoader__.load({
|
|||
useEffect(() => {
|
||||
if (!cronMode || selection.type !== 'new') return
|
||||
setForm((prev) => {
|
||||
if (prev.cwd) return prev
|
||||
const cwd = currentWorkspacePath(faces)
|
||||
return cwd ? { ...prev, cwd } : prev
|
||||
let next = prev
|
||||
if (!next.cwd) {
|
||||
const cwd = currentWorkspacePath(faces)
|
||||
if (cwd) next = { ...next, cwd }
|
||||
}
|
||||
if ((!next.provider || !next.model) && catalog.current?.provider && catalog.current?.model) {
|
||||
next = { ...next, provider: catalog.current.provider, model: catalog.current.model }
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [cronMode, selection.type])
|
||||
}, [cronMode, selection.type, catalog.current])
|
||||
|
||||
function selectJob(jobId) {
|
||||
const job = jobs.find((row) => row.id === jobId)
|
||||
|
|
@ -890,7 +957,7 @@ window.__ModuleLoader__.load({
|
|||
function selectNew() {
|
||||
skipAutoSelect.current = true
|
||||
setSelection({ type: 'new' })
|
||||
const next = emptyForm()
|
||||
const next = emptyForm(undefined, catalog.current)
|
||||
next.cwd = currentWorkspacePath(faces)
|
||||
setForm(next)
|
||||
}
|
||||
|
|
@ -921,6 +988,7 @@ window.__ModuleLoader__.load({
|
|||
const payload = {
|
||||
name: form.name, prompt: form.prompt, schedule,
|
||||
cwd: form.cwd, timeoutMinutes: form.timeoutMinutes, enabled: form.enabled,
|
||||
provider: form.provider, model: form.model,
|
||||
}
|
||||
if (selection.type === 'job' && selection.jobId) {
|
||||
await api(`/jobs/${selection.jobId}`, { method: 'PATCH', body: JSON.stringify(payload) })
|
||||
|
|
@ -965,6 +1033,7 @@ window.__ModuleLoader__.load({
|
|||
nextRunAt: selectedJob?.nextRunAt,
|
||||
lastRunAt: selectedJob?.lastRunAt,
|
||||
workspaces,
|
||||
catalog,
|
||||
onSave: save,
|
||||
onRun: () => selection.jobId && run(selection.jobId),
|
||||
onPause: () => {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ export function publicJob(job) {
|
|||
enabled: job.enabled !== false,
|
||||
cwd: job.cwd || '',
|
||||
timeoutMinutes: job.timeoutMinutes || 10,
|
||||
provider: job.provider || '',
|
||||
model: job.model || '',
|
||||
reasoningEffort: job.reasoningEffort || '',
|
||||
schedule: job.schedule,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
|
|
|
|||
72
lib/host.js
72
lib/host.js
|
|
@ -156,6 +156,9 @@ export function createHostService(options = {}) {
|
|||
schedule: patch.schedule !== undefined ? patch.schedule : job.schedule,
|
||||
cwd: patch.cwd !== undefined ? patch.cwd : job.cwd,
|
||||
timeoutMinutes: patch.timeoutMinutes !== undefined ? patch.timeoutMinutes : job.timeoutMinutes,
|
||||
provider: patch.provider !== undefined ? patch.provider : job.provider,
|
||||
model: patch.model !== undefined ? patch.model : job.model,
|
||||
reasoningEffort: patch.reasoningEffort !== undefined ? patch.reasoningEffort : job.reasoningEffort,
|
||||
}
|
||||
const record = createJobRecord(nextInput, current, t)
|
||||
record.createdAt = job.createdAt
|
||||
|
|
@ -346,6 +349,14 @@ export function createHostService(options = {}) {
|
|||
return
|
||||
}
|
||||
|
||||
if (path === `${API_PREFIX}/models` && method === 'GET') {
|
||||
const catalog = typeof sessionPort?.listModels === 'function'
|
||||
? await sessionPort.listModels()
|
||||
: { groups: [], current: null }
|
||||
write(200, { ok: true, ...catalog })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === `${API_PREFIX}/workspaces` && method === 'GET') {
|
||||
const workspaces = typeof sessionPort?.listWorkspaces === 'function'
|
||||
? await sessionPort.listWorkspaces()
|
||||
|
|
@ -695,6 +706,61 @@ export async function unarchiveSession(ctx, sessionId) {
|
|||
* Snapshot the same default model a New Session uses.
|
||||
* Persona templates interpolate `{{model}}`; an empty value fails assembly.
|
||||
*/
|
||||
export function currentDefaultModel(ctx) {
|
||||
try {
|
||||
const selection = tryGet(ctx, 'agentDefaultModel')?.currentSelection?.()
|
||||
const provider = typeof selection?.provider === 'string' ? selection.provider.trim() : ''
|
||||
const model = typeof selection?.model === 'string' ? selection.model.trim() : ''
|
||||
if (!provider || !model) return null
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort },
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function listModelChoices(ctx) {
|
||||
const llm = tryGet(ctx, 'llm')
|
||||
const providers = typeof llm?.listProviders === 'function' ? llm.listProviders() : []
|
||||
const list = Array.isArray(providers) ? providers : []
|
||||
const groups = []
|
||||
for (const row of list) {
|
||||
const provider = String(row?.id || row?.provider || '').trim()
|
||||
if (!provider) continue
|
||||
let models = []
|
||||
try {
|
||||
models = typeof llm.listModels === 'function' ? await llm.listModels(provider) : []
|
||||
} catch {
|
||||
models = []
|
||||
}
|
||||
groups.push({
|
||||
provider,
|
||||
displayName: String(row?.name || row?.displayName || provider),
|
||||
models: (Array.isArray(models) ? models : []).map((entry) => ({
|
||||
id: String(entry?.id || entry?.model || '').trim(),
|
||||
name: String(entry?.name || entry?.displayName || entry?.id || '').trim(),
|
||||
})).filter((entry) => entry.id),
|
||||
})
|
||||
}
|
||||
return { groups, current: currentDefaultModel(ctx) }
|
||||
}
|
||||
|
||||
export async function resolveJobModel(ctx, job) {
|
||||
const provider = typeof job?.provider === 'string' ? job.provider.trim() : ''
|
||||
const model = typeof job?.model === 'string' ? job.model.trim() : ''
|
||||
if (provider && model) {
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
...job.reasoningEffort ? { reasoningEffort: job.reasoningEffort } : {},
|
||||
}
|
||||
}
|
||||
return resolveDefaultModel(ctx)
|
||||
}
|
||||
|
||||
export async function resolveDefaultModel(ctx, options = {}) {
|
||||
const waitMs = Number.isFinite(options.waitMs) ? Math.max(0, options.waitMs) : 1500
|
||||
let service = tryGet(ctx, 'agentDefaultModel')
|
||||
|
|
@ -786,13 +852,14 @@ export function makeLiveSessionPort(ctx) {
|
|||
const placement = resolveSessionPlacement(ctx, job)
|
||||
const cwd = placement.cwd || defaultCwd()
|
||||
await mkdir(cwd, { recursive: true })
|
||||
const selection = await resolveDefaultModel(ctx)
|
||||
const selection = await resolveJobModel(ctx, job)
|
||||
const composition = await composeCronAgent(ctx, selection)
|
||||
const handle = await agents.create({
|
||||
sessionId,
|
||||
agentOptions: {
|
||||
provider: selection.provider,
|
||||
model: selection.model,
|
||||
...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort },
|
||||
},
|
||||
meta: {
|
||||
cwd,
|
||||
|
|
@ -847,6 +914,9 @@ export function makeLiveSessionPort(ctx) {
|
|||
async listWorkspaces() {
|
||||
return listWorkspaceChoices(ctx)
|
||||
},
|
||||
async listModels() {
|
||||
return listModelChoices(ctx)
|
||||
},
|
||||
async waitForTurn(sessionId) {
|
||||
const entry = handles.get(sessionId)
|
||||
const agents = tryGet(ctx, 'agents')
|
||||
|
|
|
|||
6
lib/index.d.ts
vendored
6
lib/index.d.ts
vendored
|
|
@ -17,8 +17,14 @@ export function createHostService(options?: object): object
|
|||
export function makeLiveSessionPort(ctx: object): object
|
||||
export function resolveSessionPlacement(ctx: object, job?: object): { cwd: string, workspace: object | null }
|
||||
export function listWorkspaceChoices(ctx: object): Array<{ id: string, title: string, path: string }>
|
||||
export function listModelChoices(ctx: object): Promise<{ groups: Array<{ provider: string, displayName: string, models: Array<{ id: string, name: string }> }>, current: { provider: string, model: string, reasoningEffort?: string } | null }>
|
||||
export function resolveJobModel(ctx: object, job?: object): Promise<{ provider: string, model: string, reasoningEffort?: string }>
|
||||
export function currentDefaultModel(ctx: object): { provider: string, model: string, reasoningEffort?: string } | null
|
||||
export function normalizeJobModel(input?: object): { provider: string, model: string, reasoningEffort: string }
|
||||
export function callerWorkingDirectory(exec?: object): string
|
||||
export function resolveCreateCwd(args?: object, exec?: object): string
|
||||
export function resolveCreateModel(args?: object, exec?: object): { provider: string, model: string, reasoningEffort: string }
|
||||
export function callerModelSelection(exec?: object): { provider: string, model: string, reasoningEffort: string }
|
||||
export function scheduleFromArgs(args?: object, nowMs?: number): { kind: string, at?: string, expr?: string, timezone: string }
|
||||
export function adoptSessionIntoWorkspace(ctx: object, sessionId: string): Promise<{ ok: boolean, attached: boolean, sessionId?: string, cwd?: string | null, workspaceId?: string | null }>
|
||||
export function shouldHideNativeWorkspaceGroup(input: { sessionRowCount: number, visibleSessionCount: number, ungrouped?: boolean, cronLabeled?: boolean }): boolean
|
||||
|
|
|
|||
|
|
@ -26,13 +26,16 @@ export {
|
|||
attachLiveSessionToWorkspace,
|
||||
bindModelSelection,
|
||||
createHostService,
|
||||
currentDefaultModel,
|
||||
listModelChoices,
|
||||
listWorkspaceChoices,
|
||||
makeLiveSessionPort,
|
||||
resolveDefaultModel,
|
||||
resolveJobModel,
|
||||
resolveSessionPlacement,
|
||||
waitForAgentTurn,
|
||||
} from './host.js'
|
||||
export { callerWorkingDirectory, registerCronTools, resolveCreateCwd, scheduleFromArgs } from './tools.js'
|
||||
export { callerWorkingDirectory, callerModelSelection, registerCronTools, resolveCreateCwd, resolveCreateModel, scheduleFromArgs } from './tools.js'
|
||||
export { decideDispatch, nextFire, parseCron, tickJobs, validateSchedule } from './scheduler.js'
|
||||
export {
|
||||
applyRunIsolation,
|
||||
|
|
@ -43,6 +46,7 @@ export {
|
|||
workspaceVisibleIds,
|
||||
} from './isolation.js'
|
||||
export { wrapScheduledPrompt } from './prompt.js'
|
||||
export { normalizeJobModel } from './store.js'
|
||||
export { claimOccurrence, executeClaimedRun, extractAssistantText, TITLE_PREFIX } from './fire.js'
|
||||
|
||||
function loadPkg(id) {
|
||||
|
|
|
|||
21
lib/store.js
21
lib/store.js
|
|
@ -53,6 +53,23 @@ export function newId() {
|
|||
return randomUUID()
|
||||
}
|
||||
|
||||
export function normalizeJobModel(input = {}) {
|
||||
const provider = typeof input.provider === 'string' ? input.provider.trim() : ''
|
||||
const model = typeof input.model === 'string' ? input.model.trim() : ''
|
||||
const reasoningEffort = typeof input.reasoningEffort === 'string'
|
||||
? input.reasoningEffort.trim()
|
||||
: (typeof input.reasoning_effort === 'string' ? input.reasoning_effort.trim() : '')
|
||||
if (!provider && !model) {
|
||||
return { provider: '', model: '', reasoningEffort: '' }
|
||||
}
|
||||
if (!provider || !model) {
|
||||
const error = new Error('provider and model must be set together')
|
||||
error.code = 'INVALID_JOB'
|
||||
throw error
|
||||
}
|
||||
return { provider, model, reasoningEffort }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} input
|
||||
* @param {object} state
|
||||
|
|
@ -74,6 +91,7 @@ export function createJobRecord(input, state, now) {
|
|||
const settings = state.settings || DEFAULT_SETTINGS
|
||||
const schedule = validateSchedule(input.schedule, settings.timezone)
|
||||
const timeoutMinutes = Number(input.timeoutMinutes)
|
||||
const model = normalizeJobModel(input)
|
||||
const job = {
|
||||
id: String(input.id || newId()),
|
||||
name,
|
||||
|
|
@ -83,6 +101,9 @@ export function createJobRecord(input, state, now) {
|
|||
timeoutMinutes: Number.isFinite(timeoutMinutes) && timeoutMinutes >= 1
|
||||
? Math.min(240, Math.round(timeoutMinutes))
|
||||
: 10,
|
||||
provider: model.provider,
|
||||
model: model.model,
|
||||
reasoningEffort: model.reasoningEffort,
|
||||
schedule: schedule.kind === 'cron'
|
||||
? { kind: 'cron', expr: schedule.expr, timezone: schedule.timezone }
|
||||
: { kind: 'at', at: new Date(schedule.at).toISOString(), timezone: schedule.timezone },
|
||||
|
|
|
|||
40
lib/tools.js
40
lib/tools.js
|
|
@ -15,6 +15,9 @@ const JOB_SCHEMA = {
|
|||
enabled: { type: 'boolean' },
|
||||
cwd: { type: 'string' },
|
||||
timeoutMinutes: { type: 'number' },
|
||||
provider: { type: 'string' },
|
||||
model: { type: 'string' },
|
||||
reasoningEffort: { type: 'string' },
|
||||
schedule: { type: 'object', additionalProperties: true },
|
||||
createdAt: { type: 'number' },
|
||||
updatedAt: { type: 'number' },
|
||||
|
|
@ -89,7 +92,8 @@ function jobLine(job) {
|
|||
const sched = job.schedule?.kind === 'at'
|
||||
? `at ${job.schedule.at}`
|
||||
: (job.schedule?.expr || 'cron')
|
||||
return `${job.name} [${state}] ${sched} tz=${tz} cwd=${job.cwd || '(recent workspace)'} next=${when} id=${job.id}`
|
||||
const model = job.provider && job.model ? `${job.provider}/${job.model}` : 'default-model'
|
||||
return `${job.name} [${state}] ${sched} tz=${tz} cwd=${job.cwd || '(recent workspace)'} model=${model} next=${when} id=${job.id}`
|
||||
}
|
||||
|
||||
export function callerWorkingDirectory(exec) {
|
||||
|
|
@ -103,6 +107,26 @@ export function resolveCreateCwd(args, exec) {
|
|||
return callerWorkingDirectory(exec)
|
||||
}
|
||||
|
||||
export function callerModelSelection(exec) {
|
||||
const opts = exec?.agent?.options || {}
|
||||
const provider = String(opts.provider || '').trim()
|
||||
const model = String(opts.model || '').trim()
|
||||
if (!provider || !model) return { provider: '', model: '', reasoningEffort: '' }
|
||||
const reasoningEffort = String(opts.reasoningEffort || '').trim()
|
||||
return { provider, model, reasoningEffort }
|
||||
}
|
||||
|
||||
export function resolveCreateModel(args, exec) {
|
||||
const provider = typeof args?.provider === 'string' ? args.provider.trim() : ''
|
||||
const model = typeof args?.model === 'string' ? args.model.trim() : ''
|
||||
const reasoningEffort = typeof args?.reasoning_effort === 'string'
|
||||
? args.reasoning_effort.trim()
|
||||
: (typeof args?.reasoningEffort === 'string' ? args.reasoningEffort.trim() : '')
|
||||
if (provider && model) return { provider, model, reasoningEffort }
|
||||
const fromSession = callerModelSelection(exec)
|
||||
return { ...fromSession, reasoningEffort: reasoningEffort || fromSession.reasoningEffort }
|
||||
}
|
||||
|
||||
function requireId(args) {
|
||||
const id = typeof args?.id === 'string' ? args.id.trim() : ''
|
||||
if (!id) {
|
||||
|
|
@ -117,7 +141,7 @@ export function cronToolDefinitions(service) {
|
|||
return [
|
||||
{
|
||||
name: 'cron_create',
|
||||
description: 'Create a DSH 定时任务 (sidebar scheduled job). NEVER use crontab. NOT schedule_create. Default timezone Asia/Shanghai. For "in N minutes / 一分钟后" pass only after_minutes. For "today/tonight at HH:MM" pass only hour+minute (24h; hour=0 is midnight). Do not also send at or expr. Extra schedule fields are ignored (after_minutes > hour > at > expr). cwd MUST be the current workspace path when chatting in a workspace; if omitted, uses the current session working directory.',
|
||||
description: 'Create a DSH 定时任务 (sidebar scheduled job). NEVER use crontab. NOT schedule_create. Default timezone Asia/Shanghai. For "in N minutes / 一分钟后" pass only after_minutes. For "today/tonight at HH:MM" pass only hour+minute (24h; hour=0 is midnight). Do not also send at or expr. Extra schedule fields are ignored (after_minutes > hour > at > expr). cwd MUST be the current workspace path when chatting in a workspace; if omitted, uses the current session working directory. Pass provider+model for the job; if omitted, snapshots the current session model. Scheduled runs consume that model\'s quota, not whatever the user happens to have selected later.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
|
|
@ -134,6 +158,9 @@ export function cronToolDefinitions(service) {
|
|||
timezone: { type: 'string', description: 'IANA timezone for expr/at. Default Asia/Shanghai. Do not pass UTC unless the user asked for UTC.' },
|
||||
time_zone: { type: 'string', description: 'Alias of timezone.' },
|
||||
cwd: { type: 'string', description: 'Filesystem path of the workspace this job should run in. When the user is in a workspace conversation, pass THAT workspace path (current session cwd). If omitted, the current session cwd is used. Only skip this to isolate from the project if the user asked for a custom folder.' },
|
||||
provider: { type: 'string', description: 'Provider route for this job (e.g. minimax-cn, deepseek). Must be passed with model. If omitted, the current session model is stored so quota stays predictable.' },
|
||||
model: { type: 'string', description: 'Model id for this job. Must be passed with provider. Scheduled runs bill this model.' },
|
||||
reasoning_effort: { type: 'string', description: 'Optional reasoning effort for this job.' },
|
||||
timeout_minutes: { type: 'integer', description: 'Per-run timeout in minutes, 1-240.' },
|
||||
enabled: { type: 'boolean', description: 'If false, create paused. Default true.' },
|
||||
},
|
||||
|
|
@ -153,7 +180,10 @@ export function cronToolDefinitions(service) {
|
|||
? `one-shot ${job.schedule.at}`
|
||||
: `cron ${job?.schedule?.expr}`
|
||||
const cwd = job?.cwd ? ` cwd=${job.cwd}` : ' cwd=(recent workspace)'
|
||||
return text(`Created "${job?.name}" (${job?.id}) ${sched} in ${tz}.${cwd}. Next run: ${when}. If that local time is wrong, delete this job and recreate with 24h hours (22=10pm).`)
|
||||
const model = job?.provider && job?.model
|
||||
? ` model=${job.provider}/${job.model}`
|
||||
: ' model=(new-session default at fire time)'
|
||||
return text(`Created "${job?.name}" (${job?.id}) ${sched} in ${tz}.${cwd}.${model}. Next run: ${when}. If that local time is wrong, delete this job and recreate with 24h hours (22=10pm).`)
|
||||
},
|
||||
},
|
||||
presentCall: (args) => ({ card: 'generic', title: '创建定时任务', content: String(args?.name || '') }),
|
||||
|
|
@ -166,6 +196,7 @@ export function cronToolDefinitions(service) {
|
|||
prompt: args.prompt,
|
||||
schedule: scheduleFromArgs(args, Date.now()),
|
||||
cwd: resolveCreateCwd(args, exec),
|
||||
...resolveCreateModel(args, exec),
|
||||
timeoutMinutes: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
|
||||
enabled: args.enabled !== false,
|
||||
})
|
||||
|
|
@ -301,6 +332,7 @@ export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai')
|
|||
'For "in N minutes / 一分钟后", call cron_create with after_minutes=N only (do not also pass at, hour, or expr).',
|
||||
'For a clock time tonight, pass only hour and minute in 24h (晚上11点34 → hour=23, minute=34; 零点33 → hour=0, minute=33). Extra at/expr fields are ignored.',
|
||||
'Working directory: if the user is chatting in a workspace, pass cwd as that workspace filesystem path (the current session working directory). If they name another workspace, use that path. If cwd is omitted, cron_create uses the current session cwd. Do not invent ~/.dsh/cron-tasks/workspace unless they asked for an isolated folder.',
|
||||
'Model: pass provider+model for the job. If omitted, cron_create stores the current session model. Scheduled runs consume that model\'s quota. Ask which model if the user cares about billing.',
|
||||
'When the user asks to look at, create, pause, resume, or delete 定时任务 / scheduled tasks / cron jobs:',
|
||||
'1. If cron_list / cron_create / cron_pause / cron_resume / cron_delete are in your tool list, call them.',
|
||||
'2. If they are not listed, search/unlock tools or skills with query "定时任务" or "cron" (skill_search, skill_load, or dev_tool_search) and then call them.',
|
||||
|
|
@ -321,7 +353,7 @@ export function makeCronSkill() {
|
|||
|
||||
Tools:
|
||||
- cron_list — list jobs
|
||||
- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat.
|
||||
- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model.
|
||||
- cron_pause / cron_resume / cron_delete — by id from cron_list
|
||||
`,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { adoptSessionIntoWorkspace, bindModelSelection, createHostService, listWorkspaceChoices, makeLiveSessionPort, resolveDefaultModel, resolveSessionPlacement, unarchiveSession, waitForAgentTurn } from '../lib/host.js'
|
||||
import { adoptSessionIntoWorkspace, bindModelSelection, createHostService, listModelChoices, listWorkspaceChoices, makeLiveSessionPort, resolveDefaultModel, resolveJobModel, resolveSessionPlacement, unarchiveSession, waitForAgentTurn } from '../lib/host.js'
|
||||
import { workspaceVisibleIds } from '../lib/isolation.js'
|
||||
|
||||
function createFakeAgent() {
|
||||
|
|
@ -121,11 +121,24 @@ test('host service: create, list, run-now, history, and workspace isolation', as
|
|||
assert.equal(job.enabled, true)
|
||||
assert.equal(job.cwd, '/tmp/cron-workspace')
|
||||
assert.equal(job.timeoutMinutes, 15)
|
||||
assert.equal(job.provider, '')
|
||||
assert.equal(job.model, '')
|
||||
assert.ok(job.nextRunAt > clock)
|
||||
|
||||
const pinned = await service.createJob({
|
||||
name: 'pinned-model',
|
||||
prompt: 'use this model',
|
||||
schedule: { kind: 'cron', expr: '0 10 * * *', timezone: 'Asia/Shanghai' },
|
||||
provider: 'minimax-cn',
|
||||
model: 'MiniMax-M3',
|
||||
})
|
||||
assert.equal(pinned.provider, 'minimax-cn')
|
||||
assert.equal(pinned.model, 'MiniMax-M3')
|
||||
|
||||
const listed = await service.listJobs()
|
||||
assert.equal(listed.length, 1)
|
||||
assert.equal(listed[0].id, job.id)
|
||||
assert.equal(listed.length, 2)
|
||||
assert.ok(listed.some((row) => row.id === job.id))
|
||||
assert.ok(listed.some((row) => row.id === pinned.id && row.model === 'MiniMax-M3'))
|
||||
|
||||
const result = await service.dispatchRun(job.id, 'run-now')
|
||||
assert.ok(result.run)
|
||||
|
|
@ -451,6 +464,68 @@ test('adoptSessionIntoWorkspace attaches a fork whose cwd matches a workspace',
|
|||
assert.deepEqual(attached, ['child-1'])
|
||||
})
|
||||
|
||||
test('resolveJobModel prefers a pinned job model over the New Session default', async () => {
|
||||
const ctx = {
|
||||
get(name) {
|
||||
if (name === 'agentDefaultModel') {
|
||||
return { currentSelection: () => ({ provider: 'deepseek', model: 'deepseek-chat' }) }
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
const pinned = await resolveJobModel(ctx, { provider: 'minimax-cn', model: 'MiniMax-M3' })
|
||||
assert.deepEqual(pinned, { provider: 'minimax-cn', model: 'MiniMax-M3' })
|
||||
const fallback = await resolveJobModel(ctx, { provider: '', model: '' })
|
||||
assert.equal(fallback.provider, 'deepseek')
|
||||
assert.equal(fallback.model, 'deepseek-chat')
|
||||
})
|
||||
|
||||
test('listModelChoices maps llm providers and the current default', async () => {
|
||||
const ctx = {
|
||||
get(name) {
|
||||
if (name === 'agentDefaultModel') {
|
||||
return { currentSelection: () => ({ provider: 'deepseek', model: 'deepseek-chat' }) }
|
||||
}
|
||||
if (name === 'llm') {
|
||||
return {
|
||||
listProviders: () => [{ id: 'minimax-cn', name: 'MiniMax' }],
|
||||
async listModels(provider) {
|
||||
assert.equal(provider, 'minimax-cn')
|
||||
return [{ id: 'MiniMax-M3', name: 'M3', provider }]
|
||||
},
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
const catalog = await listModelChoices(ctx)
|
||||
assert.equal(catalog.current.model, 'deepseek-chat')
|
||||
assert.deepEqual(catalog.groups, [{
|
||||
provider: 'minimax-cn',
|
||||
displayName: 'MiniMax',
|
||||
models: [{ id: 'MiniMax-M3', name: 'M3' }],
|
||||
}])
|
||||
})
|
||||
|
||||
test('GET /models lists session-port catalog', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const service = createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
sessionPort: {
|
||||
async listModels() {
|
||||
return { groups: [{ provider: 'deepseek', displayName: 'DeepSeek', models: [{ id: 'deepseek-chat', name: 'Chat' }] }], current: { provider: 'deepseek', model: 'deepseek-chat' } }
|
||||
},
|
||||
},
|
||||
})
|
||||
const http = await listen(service)
|
||||
t.after(() => http.close())
|
||||
const res = await jsonRequest(http.url, '/dsh-cron-tasks/models')
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.body.current.model, 'deepseek-chat')
|
||||
assert.equal(res.body.groups[0].models[0].id, 'deepseek-chat')
|
||||
})
|
||||
|
||||
test('listWorkspaceChoices maps registry entries to title and path', () => {
|
||||
const ctx = {
|
||||
get(name) {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ test('installable bundle declares host apply, client half, unique id, and no @de
|
|||
assert.match(host, /attachLiveSessionToWorkspace/)
|
||||
assert.match(host, /listWorkspaceChoices/)
|
||||
assert.match(host, /\/workspaces/)
|
||||
assert.match(host, /listModelChoices/)
|
||||
assert.match(host, /resolveJobModel/)
|
||||
assert.match(host, /\/models/)
|
||||
assert.doesNotMatch(host, /from ['"]@deepseek-ai\//)
|
||||
const client = await readFile(join(root, 'lib/client.js'), 'utf8')
|
||||
assert.match(client, /定时任务/)
|
||||
|
|
@ -82,6 +85,10 @@ test('installable bundle declares host apply, client half, unique id, and no @de
|
|||
assert.match(client, /cwdRecent/)
|
||||
assert.match(client, /cwdCustom/)
|
||||
assert.match(client, /CwdField/)
|
||||
assert.match(client, /ModelField/)
|
||||
assert.match(client, /api\('\/models'\)/)
|
||||
assert.match(tools, /resolveCreateModel/)
|
||||
assert.match(tools, /snapshots the current session model/)
|
||||
assert.match(client, /api\('\/workspaces'\)/)
|
||||
assert.match(client, /atToLocalInput/)
|
||||
assert.match(client, /localInputToAt/)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
|
|||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { createHostService } from '../lib/host.js'
|
||||
import { callerWorkingDirectory, cronToolDefinitions, registerCronGuidance, registerCronTools, resolveCreateCwd, scheduleFromArgs } from '../lib/tools.js'
|
||||
import { callerWorkingDirectory, cronToolDefinitions, registerCronGuidance, registerCronTools, resolveCreateCwd, resolveCreateModel, scheduleFromArgs } from '../lib/tools.js'
|
||||
|
||||
async function makeService(t) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
|
||||
|
|
@ -78,11 +78,42 @@ test('cron_create cwd defaults to the calling session workspace path', async (t)
|
|||
assert.equal(explicit.job.cwd, '/tmp/isolated')
|
||||
})
|
||||
|
||||
test('cron_create snapshots the calling session model when provider is omitted', async (t) => {
|
||||
const service = await makeService(t)
|
||||
const tools = byName(cronToolDefinitions(service))
|
||||
const created = await tools.cron_create.execute({
|
||||
name: 'from-chat-model',
|
||||
prompt: 'ping',
|
||||
hour: 23,
|
||||
minute: 1,
|
||||
timezone: 'Asia/Shanghai',
|
||||
}, { agent: { options: { provider: 'minimax-cn', model: 'MiniMax-M3' }, session: { header: { cwd: '/tmp/ws-app' } } } })
|
||||
assert.equal(created.job.provider, 'minimax-cn')
|
||||
assert.equal(created.job.model, 'MiniMax-M3')
|
||||
|
||||
const explicit = await tools.cron_create.execute({
|
||||
name: 'pinned',
|
||||
prompt: 'ping',
|
||||
hour: 23,
|
||||
minute: 2,
|
||||
timezone: 'Asia/Shanghai',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-chat',
|
||||
}, { agent: { options: { provider: 'minimax-cn', model: 'MiniMax-M3' } } })
|
||||
assert.equal(explicit.job.provider, 'deepseek')
|
||||
assert.equal(explicit.job.model, 'deepseek-chat')
|
||||
})
|
||||
|
||||
test('resolveCreateCwd prefers explicit cwd then the calling session', () => {
|
||||
assert.equal(resolveCreateCwd({ cwd: '/tmp/a' }, { agent: { session: { header: { cwd: '/ws' } } } }), '/tmp/a')
|
||||
assert.equal(callerWorkingDirectory({ agent: { session: { header: { cwd: '/ws' } } } }), '/ws')
|
||||
assert.equal(resolveCreateCwd({}, { agent: { session: { header: { cwd: '/ws' } } } }), '/ws')
|
||||
assert.equal(resolveCreateCwd({}, {}), '')
|
||||
assert.deepEqual(resolveCreateModel({}, { agent: { options: { provider: 'minimax-cn', model: 'MiniMax-M3' } } }), {
|
||||
provider: 'minimax-cn',
|
||||
model: 'MiniMax-M3',
|
||||
reasoningEffort: '',
|
||||
})
|
||||
})
|
||||
|
||||
test('cron_create hour+minute uses today and rejects a guessed past calendar date', async (t) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue