@vibe-forge/mcp 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/sync.spec.ts +23 -0
- package/__tests__/task-manager.spec.ts +319 -1
- package/__tests__/task-tool.spec.ts +130 -0
- package/package.json +6 -6
- package/src/sync.ts +7 -1
- package/src/tools/task/index.ts +22 -78
- package/src/tools/task/manager.ts +493 -69
- package/src/tools/task/permission-recovery.ts +172 -0
- package/src/tools/task/permission-state.ts +200 -0
- package/src/tools/task/presentation.ts +95 -0
- package/src/tools/task/register-task-runtime-tools.ts +145 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdapterErrorData,
|
|
3
|
+
AskUserQuestionParams,
|
|
4
|
+
PermissionInteractionContext,
|
|
5
|
+
PermissionInteractionDecision,
|
|
6
|
+
SessionPermissionMode
|
|
7
|
+
} from '@vibe-forge/types'
|
|
8
|
+
import { normalizePermissionToolName } from '@vibe-forge/utils'
|
|
9
|
+
import type { PermissionToolSubject } from '@vibe-forge/utils'
|
|
10
|
+
|
|
11
|
+
export { applyTaskPermissionDecision, syncTaskPermissionStateMirror } from './permission-state'
|
|
12
|
+
|
|
13
|
+
const PERMISSION_PROJECT_CONFIG_PATH = '.ai.config.json'
|
|
14
|
+
export const PERMISSION_DECISION_CANCEL = 'cancel'
|
|
15
|
+
|
|
16
|
+
const uniqueStrings = (values: string[]) => [...new Set(values)]
|
|
17
|
+
|
|
18
|
+
const normalizeKeys = (values: string[]) =>
|
|
19
|
+
uniqueStrings(
|
|
20
|
+
values
|
|
21
|
+
.map((value) => normalizePermissionToolName(value)?.key ?? value.trim())
|
|
22
|
+
.filter((value): value is string => value.trim() !== '')
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const buildPermissionOption = (
|
|
26
|
+
label: string,
|
|
27
|
+
value: PermissionInteractionDecision,
|
|
28
|
+
description: string
|
|
29
|
+
) => ({ label, value, description })
|
|
30
|
+
|
|
31
|
+
const resolvePermissionToolSubject = (value: string): PermissionToolSubject | undefined => (
|
|
32
|
+
normalizePermissionToolName(value)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
export interface PermissionErrorContext {
|
|
36
|
+
subjectKeys: string[]
|
|
37
|
+
deniedTools: string[]
|
|
38
|
+
reasons: string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const extractPermissionErrorContext = (error: AdapterErrorData): PermissionErrorContext => {
|
|
42
|
+
const details = error.details != null && typeof error.details === 'object'
|
|
43
|
+
? error.details as Record<string, unknown>
|
|
44
|
+
: {}
|
|
45
|
+
const rawDeniedTools = new Set<string>()
|
|
46
|
+
const reasons = new Set<string>()
|
|
47
|
+
|
|
48
|
+
const permissionDenials = Array.isArray(details.permissionDenials) ? details.permissionDenials : []
|
|
49
|
+
for (const denial of permissionDenials) {
|
|
50
|
+
if (denial == null || typeof denial !== 'object') continue
|
|
51
|
+
const record = denial as Record<string, unknown>
|
|
52
|
+
if (typeof record.message === 'string' && record.message.trim() !== '') {
|
|
53
|
+
reasons.add(record.message.trim())
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(record.deniedTools)) {
|
|
56
|
+
for (const tool of record.deniedTools) {
|
|
57
|
+
if (typeof tool === 'string' && tool.trim() !== '') {
|
|
58
|
+
rawDeniedTools.add(tool.trim())
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (Array.isArray(details.deniedTools)) {
|
|
65
|
+
for (const tool of details.deniedTools) {
|
|
66
|
+
if (typeof tool === 'string' && tool.trim() !== '') {
|
|
67
|
+
rawDeniedTools.add(tool.trim())
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (typeof details.toolName === 'string' && details.toolName.trim() !== '') {
|
|
73
|
+
rawDeniedTools.add(details.toolName.trim())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof error.message === 'string' && error.message.trim() !== '') {
|
|
77
|
+
reasons.add(error.message.trim())
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const deniedTools = [...rawDeniedTools]
|
|
81
|
+
const subjectKeys = uniqueStrings(
|
|
82
|
+
deniedTools
|
|
83
|
+
.map(tool => resolvePermissionToolSubject(tool)?.key)
|
|
84
|
+
.filter((key): key is string => key != null && key.trim() !== '')
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
subjectKeys,
|
|
89
|
+
deniedTools,
|
|
90
|
+
reasons: [...reasons]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const resolvePermissionInteractionDecision = (
|
|
95
|
+
answer: string | string[]
|
|
96
|
+
): PermissionInteractionDecision | typeof PERMISSION_DECISION_CANCEL | undefined => {
|
|
97
|
+
const normalizedAnswer = Array.isArray(answer) ? answer[0] : answer
|
|
98
|
+
if (typeof normalizedAnswer !== 'string') return undefined
|
|
99
|
+
|
|
100
|
+
const raw = normalizedAnswer.trim()
|
|
101
|
+
if (raw === '') return undefined
|
|
102
|
+
if (raw === PERMISSION_DECISION_CANCEL) return PERMISSION_DECISION_CANCEL
|
|
103
|
+
|
|
104
|
+
if (
|
|
105
|
+
raw === 'allow_once' ||
|
|
106
|
+
raw === 'allow_session' ||
|
|
107
|
+
raw === 'allow_project' ||
|
|
108
|
+
raw === 'deny_once' ||
|
|
109
|
+
raw === 'deny_session' ||
|
|
110
|
+
raw === 'deny_project'
|
|
111
|
+
) {
|
|
112
|
+
return raw
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const buildPermissionRecoveryPayload = (params: {
|
|
119
|
+
sessionId: string
|
|
120
|
+
adapter?: string
|
|
121
|
+
currentMode?: SessionPermissionMode
|
|
122
|
+
context: PermissionErrorContext
|
|
123
|
+
}): AskUserQuestionParams | undefined => {
|
|
124
|
+
const subjectKeys = normalizeKeys(params.context.subjectKeys)
|
|
125
|
+
if (subjectKeys.length === 0) {
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const primarySubjectKey = subjectKeys[0] ?? 'UnknownTool'
|
|
130
|
+
const subjectLabel = subjectKeys.length <= 1
|
|
131
|
+
? primarySubjectKey
|
|
132
|
+
: `${subjectKeys[0]} 等 ${subjectKeys.length} 项工具`
|
|
133
|
+
const deniedTools = uniqueStrings([
|
|
134
|
+
...params.context.deniedTools,
|
|
135
|
+
...subjectKeys
|
|
136
|
+
])
|
|
137
|
+
const permissionContext: PermissionInteractionContext = {
|
|
138
|
+
adapter: params.adapter,
|
|
139
|
+
currentMode: params.currentMode,
|
|
140
|
+
deniedTools,
|
|
141
|
+
reasons: uniqueStrings(params.context.reasons),
|
|
142
|
+
subjectKey: primarySubjectKey,
|
|
143
|
+
subjectLabel,
|
|
144
|
+
scope: 'tool',
|
|
145
|
+
projectConfigPath: PERMISSION_PROJECT_CONFIG_PATH
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
sessionId: params.sessionId,
|
|
150
|
+
kind: 'permission',
|
|
151
|
+
question: subjectKeys.length <= 1
|
|
152
|
+
? `当前任务需要使用 ${subjectLabel} 才能继续,请选择处理方式。`
|
|
153
|
+
: `当前任务涉及 ${subjectKeys.join('、')} 等工具,请选择处理方式。`,
|
|
154
|
+
options: [
|
|
155
|
+
buildPermissionOption('同意本次', 'allow_once', '仅继续这次被拦截的操作。'),
|
|
156
|
+
buildPermissionOption('同意并在当前会话忽略类似调用', 'allow_session', '本会话内同类工具不再重复询问。'),
|
|
157
|
+
buildPermissionOption(
|
|
158
|
+
'同意并在当前项目忽略类似调用',
|
|
159
|
+
'allow_project',
|
|
160
|
+
`写入 ${PERMISSION_PROJECT_CONFIG_PATH},后续新会话仍生效。`
|
|
161
|
+
),
|
|
162
|
+
buildPermissionOption('拒绝本次', 'deny_once', '拒绝当前这次操作。'),
|
|
163
|
+
buildPermissionOption('拒绝并在当前会话阻止类似调用', 'deny_session', '本会话内同类工具直接拒绝。'),
|
|
164
|
+
buildPermissionOption(
|
|
165
|
+
'拒绝并在当前项目阻止类似调用',
|
|
166
|
+
'deny_project',
|
|
167
|
+
`写入 ${PERMISSION_PROJECT_CONFIG_PATH},后续新会话仍生效。`
|
|
168
|
+
)
|
|
169
|
+
],
|
|
170
|
+
permissionContext
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
import { buildConfigJsonVariables, loadConfig, updateConfigFile } from '@vibe-forge/config'
|
|
6
|
+
import type { Config, PermissionInteractionDecision } from '@vibe-forge/types'
|
|
7
|
+
import {
|
|
8
|
+
normalizePermissionToolName,
|
|
9
|
+
normalizeSessionPermissionState,
|
|
10
|
+
resolvePermissionMirrorPath
|
|
11
|
+
} from '@vibe-forge/utils'
|
|
12
|
+
import type { SessionPermissionState } from '@vibe-forge/utils'
|
|
13
|
+
|
|
14
|
+
const PERMISSION_PROJECT_CONFIG_PATH = '.ai.config.json'
|
|
15
|
+
|
|
16
|
+
const uniqueStrings = (values: string[]) => [...new Set(values)]
|
|
17
|
+
|
|
18
|
+
const normalizeKeys = (values: string[]) =>
|
|
19
|
+
uniqueStrings(
|
|
20
|
+
values
|
|
21
|
+
.map((value) => normalizePermissionToolName(value)?.key ?? value.trim())
|
|
22
|
+
.filter((value): value is string => value.trim() !== '')
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const removeKeys = (values: string[], keys: Set<string>) => (
|
|
26
|
+
values.filter((value) => {
|
|
27
|
+
const normalized = normalizePermissionToolName(value)?.key ?? value.trim()
|
|
28
|
+
return !keys.has(normalized)
|
|
29
|
+
})
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const buildGeneralSectionValue = (config: Config | undefined, permissions: Config['permissions']) => ({
|
|
33
|
+
baseDir: config?.baseDir,
|
|
34
|
+
effort: config?.effort,
|
|
35
|
+
defaultAdapter: config?.defaultAdapter,
|
|
36
|
+
defaultModelService: config?.defaultModelService,
|
|
37
|
+
defaultModel: config?.defaultModel,
|
|
38
|
+
recommendedModels: config?.recommendedModels,
|
|
39
|
+
interfaceLanguage: config?.interfaceLanguage,
|
|
40
|
+
modelLanguage: config?.modelLanguage,
|
|
41
|
+
announcements: config?.announcements,
|
|
42
|
+
permissions,
|
|
43
|
+
env: config?.env,
|
|
44
|
+
notifications: config?.notifications,
|
|
45
|
+
shortcuts: config?.shortcuts
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const mutateSessionPermissionState = (
|
|
49
|
+
state: SessionPermissionState,
|
|
50
|
+
keys: string[],
|
|
51
|
+
action: PermissionInteractionDecision
|
|
52
|
+
): SessionPermissionState => {
|
|
53
|
+
const targetKeys = normalizeKeys(keys)
|
|
54
|
+
const keySet = new Set(targetKeys)
|
|
55
|
+
const next = normalizeSessionPermissionState(state)
|
|
56
|
+
|
|
57
|
+
switch (action) {
|
|
58
|
+
case 'allow_once':
|
|
59
|
+
next.onceAllow = uniqueStrings([...removeKeys(next.onceAllow, keySet), ...targetKeys])
|
|
60
|
+
next.onceDeny = removeKeys(next.onceDeny, keySet)
|
|
61
|
+
return next
|
|
62
|
+
case 'allow_session':
|
|
63
|
+
next.allow = uniqueStrings([...removeKeys(next.allow, keySet), ...targetKeys])
|
|
64
|
+
next.deny = removeKeys(next.deny, keySet)
|
|
65
|
+
next.onceDeny = removeKeys(next.onceDeny, keySet)
|
|
66
|
+
next.onceAllow = removeKeys(next.onceAllow, keySet)
|
|
67
|
+
return next
|
|
68
|
+
case 'allow_project':
|
|
69
|
+
next.allow = uniqueStrings([...removeKeys(next.allow, keySet), ...targetKeys])
|
|
70
|
+
next.deny = removeKeys(next.deny, keySet)
|
|
71
|
+
next.onceDeny = removeKeys(next.onceDeny, keySet)
|
|
72
|
+
next.onceAllow = removeKeys(next.onceAllow, keySet)
|
|
73
|
+
return next
|
|
74
|
+
case 'deny_once':
|
|
75
|
+
return next
|
|
76
|
+
case 'deny_session':
|
|
77
|
+
next.deny = uniqueStrings([...removeKeys(next.deny, keySet), ...targetKeys])
|
|
78
|
+
next.allow = removeKeys(next.allow, keySet)
|
|
79
|
+
next.onceAllow = removeKeys(next.onceAllow, keySet)
|
|
80
|
+
next.onceDeny = removeKeys(next.onceDeny, keySet)
|
|
81
|
+
return next
|
|
82
|
+
case 'deny_project':
|
|
83
|
+
next.deny = uniqueStrings([...removeKeys(next.deny, keySet), ...targetKeys])
|
|
84
|
+
next.allow = removeKeys(next.allow, keySet)
|
|
85
|
+
next.onceAllow = removeKeys(next.onceAllow, keySet)
|
|
86
|
+
next.onceDeny = removeKeys(next.onceDeny, keySet)
|
|
87
|
+
return next
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const loadTaskConfig = async (cwd: string) =>
|
|
92
|
+
await loadConfig({
|
|
93
|
+
cwd,
|
|
94
|
+
jsonVariables: buildConfigJsonVariables(cwd, process.env)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const buildMergedProjectPermissions = async (cwd: string) => {
|
|
98
|
+
const [projectConfig, userConfig] = await loadTaskConfig(cwd)
|
|
99
|
+
return {
|
|
100
|
+
allow: [
|
|
101
|
+
...(projectConfig?.permissions?.allow ?? []),
|
|
102
|
+
...(userConfig?.permissions?.allow ?? [])
|
|
103
|
+
],
|
|
104
|
+
deny: [
|
|
105
|
+
...(projectConfig?.permissions?.deny ?? []),
|
|
106
|
+
...(userConfig?.permissions?.deny ?? [])
|
|
107
|
+
],
|
|
108
|
+
ask: [
|
|
109
|
+
...(projectConfig?.permissions?.ask ?? []),
|
|
110
|
+
...(userConfig?.permissions?.ask ?? [])
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const updateProjectPermissionLists = async (
|
|
116
|
+
cwd: string,
|
|
117
|
+
keys: string[],
|
|
118
|
+
target: 'allow' | 'deny'
|
|
119
|
+
) => {
|
|
120
|
+
const targetKeys = normalizeKeys(keys)
|
|
121
|
+
const keySet = new Set(targetKeys)
|
|
122
|
+
const [projectConfig] = await loadTaskConfig(cwd)
|
|
123
|
+
const existingPermissions = projectConfig?.permissions ?? {}
|
|
124
|
+
const nextPermissions: Config['permissions'] = {
|
|
125
|
+
...existingPermissions,
|
|
126
|
+
allow: removeKeys(existingPermissions.allow ?? [], keySet),
|
|
127
|
+
deny: removeKeys(existingPermissions.deny ?? [], keySet),
|
|
128
|
+
ask: removeKeys(existingPermissions.ask ?? [], keySet)
|
|
129
|
+
}
|
|
130
|
+
nextPermissions[target] = uniqueStrings([...(nextPermissions[target] ?? []), ...targetKeys])
|
|
131
|
+
|
|
132
|
+
await updateConfigFile({
|
|
133
|
+
workspaceFolder: cwd,
|
|
134
|
+
source: 'project',
|
|
135
|
+
section: 'general',
|
|
136
|
+
value: buildGeneralSectionValue(projectConfig, nextPermissions)
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const syncTaskPermissionStateMirror = async (params: {
|
|
141
|
+
cwd: string
|
|
142
|
+
adapter?: string
|
|
143
|
+
sessionId: string
|
|
144
|
+
permissionState: SessionPermissionState
|
|
145
|
+
}) => {
|
|
146
|
+
if (params.adapter !== 'claude-code' && params.adapter !== 'opencode') {
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const mirrorPath = resolvePermissionMirrorPath(params.cwd, params.adapter, params.sessionId)
|
|
151
|
+
const projectPermissions = await buildMergedProjectPermissions(params.cwd)
|
|
152
|
+
await mkdir(dirname(mirrorPath), { recursive: true })
|
|
153
|
+
await writeFile(
|
|
154
|
+
mirrorPath,
|
|
155
|
+
`${
|
|
156
|
+
JSON.stringify(
|
|
157
|
+
{
|
|
158
|
+
sessionId: params.sessionId,
|
|
159
|
+
adapter: params.adapter,
|
|
160
|
+
permissionState: normalizeSessionPermissionState(params.permissionState),
|
|
161
|
+
projectPermissions,
|
|
162
|
+
updatedAt: Date.now()
|
|
163
|
+
},
|
|
164
|
+
null,
|
|
165
|
+
2
|
|
166
|
+
)
|
|
167
|
+
}\n`,
|
|
168
|
+
'utf8'
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const applyTaskPermissionDecision = async (params: {
|
|
173
|
+
cwd: string
|
|
174
|
+
sessionId: string
|
|
175
|
+
adapter?: string
|
|
176
|
+
permissionState: SessionPermissionState
|
|
177
|
+
subjectKeys: string[]
|
|
178
|
+
action: PermissionInteractionDecision
|
|
179
|
+
}) => {
|
|
180
|
+
const subjectKeys = normalizeKeys(params.subjectKeys)
|
|
181
|
+
if (subjectKeys.length === 0) {
|
|
182
|
+
return normalizeSessionPermissionState(params.permissionState)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (params.action === 'allow_project') await updateProjectPermissionLists(params.cwd, subjectKeys, 'allow')
|
|
186
|
+
if (params.action === 'deny_project') await updateProjectPermissionLists(params.cwd, subjectKeys, 'deny')
|
|
187
|
+
|
|
188
|
+
const nextState = mutateSessionPermissionState(
|
|
189
|
+
params.permissionState,
|
|
190
|
+
subjectKeys,
|
|
191
|
+
params.action
|
|
192
|
+
)
|
|
193
|
+
await syncTaskPermissionStateMirror({
|
|
194
|
+
cwd: params.cwd,
|
|
195
|
+
adapter: params.adapter,
|
|
196
|
+
sessionId: params.sessionId,
|
|
197
|
+
permissionState: nextState
|
|
198
|
+
})
|
|
199
|
+
return nextState
|
|
200
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
|
|
3
|
+
import type { SessionPermissionMode } from '@vibe-forge/types'
|
|
4
|
+
|
|
5
|
+
import type { TaskInfo } from './manager'
|
|
6
|
+
|
|
7
|
+
export const SESSION_PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'] as const
|
|
8
|
+
|
|
9
|
+
export const START_TASKS_DESCRIPTION =
|
|
10
|
+
'Start multiple tasks in background or foreground. If a task stalls, fails, or asks for permission/input, call GetTaskInfo. If GetTaskInfo returns pendingInput or pendingInteraction, resolve it with SubmitTaskInput. If logs show permission_required, you can answer the recovery prompt with SubmitTaskInput instead of restarting manually.'
|
|
11
|
+
|
|
12
|
+
export const GET_TASK_INFO_DESCRIPTION =
|
|
13
|
+
'Get the detailed status, logs, pendingInput, pendingInteraction, lastError, and guidance for a task. Use this when a task seems stuck, is waiting for permission/input, or has failed. If pendingInput is present, answer it with SubmitTaskInput.'
|
|
14
|
+
|
|
15
|
+
export const SUBMIT_TASK_INPUT_DESCRIPTION =
|
|
16
|
+
'Submit input for a task that is blocked waiting for permission or user input. First call GetTaskInfo or ListTasks, then use taskId plus one of pendingInput.payload.options[].value when available. Common permission answers are allow_once, allow_session, allow_project, deny_once, deny_session, or deny_project.'
|
|
17
|
+
|
|
18
|
+
export const RESPOND_TASK_INTERACTION_DESCRIPTION =
|
|
19
|
+
'Deprecated alias of SubmitTaskInput. Use SubmitTaskInput for both permission prompts and generic task input.'
|
|
20
|
+
|
|
21
|
+
export const STOP_TASK_DESCRIPTION =
|
|
22
|
+
'Stop a running or blocked task. Use this when the task is no longer needed or cannot recover cleanly.'
|
|
23
|
+
|
|
24
|
+
export const LIST_TASKS_DESCRIPTION =
|
|
25
|
+
'List all managed tasks with status, pendingInput, pendingInteraction, lastError, and guidance. Use this first to find which tasks are blocked; then call GetTaskInfo for one task or SubmitTaskInput if it is waiting for input.'
|
|
26
|
+
|
|
27
|
+
export const TASK_PERMISSION_MODE_DESCRIPTION =
|
|
28
|
+
'Permission mode for the task. If omitted, inherits the parent session. Raise it only when the task is blocked by permission errors.'
|
|
29
|
+
|
|
30
|
+
export const TASK_BACKGROUND_DESCRIPTION =
|
|
31
|
+
'Whether to run in background (default: true). If false, waits until the task completes, fails, or becomes blocked waiting for input, then returns the current logs.'
|
|
32
|
+
|
|
33
|
+
export const resolveInheritedPermissionMode = (): SessionPermissionMode | undefined => {
|
|
34
|
+
const value = process.env.__VF_PROJECT_AI_PERMISSION_MODE__?.trim()
|
|
35
|
+
if (value == null || value === '') return undefined
|
|
36
|
+
return (SESSION_PERMISSION_MODES as readonly string[]).includes(value)
|
|
37
|
+
? value as SessionPermissionMode
|
|
38
|
+
: undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const buildTaskGuidance = (task: {
|
|
42
|
+
status?: string
|
|
43
|
+
pendingInteraction?: { payload?: { options?: Array<{ label: string; value?: string }> } }
|
|
44
|
+
lastError?: { code?: string }
|
|
45
|
+
}) => {
|
|
46
|
+
const hints: string[] = []
|
|
47
|
+
|
|
48
|
+
if (task.pendingInteraction != null) {
|
|
49
|
+
const optionValues = task.pendingInteraction.payload?.options
|
|
50
|
+
?.map(option => option.value ?? option.label)
|
|
51
|
+
.filter((value): value is string => value.trim() !== '')
|
|
52
|
+
hints.push(
|
|
53
|
+
optionValues != null && optionValues.length > 0
|
|
54
|
+
? `Task is waiting for input. Call SubmitTaskInput with one of: ${optionValues.join(', ')}.`
|
|
55
|
+
: 'Task is waiting for input. Call SubmitTaskInput with the desired answer.'
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (task.lastError?.code === 'permission_required') {
|
|
60
|
+
hints.push(
|
|
61
|
+
'Task hit a permission error. Retry StartTasks with a more suitable permissionMode, or update project permissions before retrying.'
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (task.status === 'failed' && hints.length === 0) {
|
|
66
|
+
hints.push('Task failed. Inspect logs and lastError, then restart the task if needed.')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return hints
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const serializeTaskInfo = (params: {
|
|
73
|
+
taskId: string
|
|
74
|
+
description?: string
|
|
75
|
+
status?: TaskInfo['status']
|
|
76
|
+
info?: TaskInfo
|
|
77
|
+
}) => {
|
|
78
|
+
const info = params.info
|
|
79
|
+
const safeInfo = (() => {
|
|
80
|
+
if (info == null) {
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
const { session, onStop, serverSync, createdAt, ...rest } = info
|
|
84
|
+
return rest
|
|
85
|
+
})()
|
|
86
|
+
return {
|
|
87
|
+
taskId: params.taskId,
|
|
88
|
+
description: params.description ?? info?.description,
|
|
89
|
+
status: info?.status ?? params.status,
|
|
90
|
+
logs: info?.logs ?? [],
|
|
91
|
+
pendingInput: safeInfo?.pendingInteraction,
|
|
92
|
+
...safeInfo,
|
|
93
|
+
guidance: buildTaskGuidance(safeInfo ?? {})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import type { Register } from '../types'
|
|
4
|
+
import type { TaskManager } from './manager'
|
|
5
|
+
import {
|
|
6
|
+
GET_TASK_INFO_DESCRIPTION,
|
|
7
|
+
LIST_TASKS_DESCRIPTION,
|
|
8
|
+
RESPOND_TASK_INTERACTION_DESCRIPTION,
|
|
9
|
+
STOP_TASK_DESCRIPTION,
|
|
10
|
+
SUBMIT_TASK_INPUT_DESCRIPTION,
|
|
11
|
+
serializeTaskInfo
|
|
12
|
+
} from './presentation'
|
|
13
|
+
|
|
14
|
+
export const registerTaskRuntimeTools = (
|
|
15
|
+
server: Parameters<Register>[0],
|
|
16
|
+
taskManager: TaskManager
|
|
17
|
+
) => {
|
|
18
|
+
server.registerTool(
|
|
19
|
+
'GetTaskInfo',
|
|
20
|
+
{
|
|
21
|
+
title: 'Get Task Info',
|
|
22
|
+
description: GET_TASK_INFO_DESCRIPTION,
|
|
23
|
+
inputSchema: z.object({
|
|
24
|
+
taskId: z.string().describe('The ID of the task to check')
|
|
25
|
+
})
|
|
26
|
+
},
|
|
27
|
+
async ({ taskId }) => {
|
|
28
|
+
const task = taskManager.getTask(taskId)
|
|
29
|
+
if (!task) {
|
|
30
|
+
return {
|
|
31
|
+
content: [{ type: 'text', text: `Task ${taskId} not found.` }],
|
|
32
|
+
isError: true
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
content: [{
|
|
37
|
+
type: 'text',
|
|
38
|
+
text: JSON.stringify([serializeTaskInfo({ taskId, info: task })])
|
|
39
|
+
}]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
server.registerTool(
|
|
45
|
+
'SubmitTaskInput',
|
|
46
|
+
{
|
|
47
|
+
title: 'Submit Task Input',
|
|
48
|
+
description: SUBMIT_TASK_INPUT_DESCRIPTION,
|
|
49
|
+
inputSchema: z.object({
|
|
50
|
+
taskId: z.string().describe('The ID of the task that is waiting for input'),
|
|
51
|
+
interactionId: z
|
|
52
|
+
.string()
|
|
53
|
+
.describe('Optional interaction ID. Omit it when the task only has one pending input.')
|
|
54
|
+
.optional(),
|
|
55
|
+
data: z
|
|
56
|
+
.union([z.string(), z.array(z.string()).min(1)])
|
|
57
|
+
.describe('The input to submit. Prefer pendingInput.payload.options[].value when available.')
|
|
58
|
+
})
|
|
59
|
+
},
|
|
60
|
+
async ({ taskId, interactionId, data }) => {
|
|
61
|
+
await taskManager.submitTaskInput({
|
|
62
|
+
taskId,
|
|
63
|
+
interactionId,
|
|
64
|
+
data
|
|
65
|
+
})
|
|
66
|
+
const task = taskManager.getTask(taskId)
|
|
67
|
+
return {
|
|
68
|
+
content: [{
|
|
69
|
+
type: 'text',
|
|
70
|
+
text: JSON.stringify([serializeTaskInfo({ taskId, info: task })])
|
|
71
|
+
}]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
server.registerTool(
|
|
77
|
+
'RespondTaskInteraction',
|
|
78
|
+
{
|
|
79
|
+
title: 'Respond Task Interaction',
|
|
80
|
+
description: RESPOND_TASK_INTERACTION_DESCRIPTION,
|
|
81
|
+
inputSchema: z.object({
|
|
82
|
+
taskId: z.string().describe('The ID of the task that is waiting for input'),
|
|
83
|
+
interactionId: z
|
|
84
|
+
.string()
|
|
85
|
+
.describe('Optional interaction ID. Omit it when the task only has one pending interaction.')
|
|
86
|
+
.optional(),
|
|
87
|
+
response: z
|
|
88
|
+
.union([z.string(), z.array(z.string()).min(1)])
|
|
89
|
+
.describe('The selected answer. Prefer pendingInteraction.options[].value when available.')
|
|
90
|
+
})
|
|
91
|
+
},
|
|
92
|
+
async ({ taskId, interactionId, response }) => {
|
|
93
|
+
await taskManager.submitTaskInput({
|
|
94
|
+
taskId,
|
|
95
|
+
interactionId,
|
|
96
|
+
data: response
|
|
97
|
+
})
|
|
98
|
+
const task = taskManager.getTask(taskId)
|
|
99
|
+
return {
|
|
100
|
+
content: [{
|
|
101
|
+
type: 'text',
|
|
102
|
+
text: JSON.stringify([serializeTaskInfo({ taskId, info: task })])
|
|
103
|
+
}]
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
server.registerTool(
|
|
109
|
+
'StopTask',
|
|
110
|
+
{
|
|
111
|
+
title: 'Stop Task',
|
|
112
|
+
description: STOP_TASK_DESCRIPTION,
|
|
113
|
+
inputSchema: z.object({
|
|
114
|
+
taskId: z.string().describe('The ID of the task to stop')
|
|
115
|
+
})
|
|
116
|
+
},
|
|
117
|
+
async ({ taskId }) => {
|
|
118
|
+
const success = taskManager.stopTask(taskId)
|
|
119
|
+
return {
|
|
120
|
+
content: [{
|
|
121
|
+
type: 'text',
|
|
122
|
+
text: success ? `Task ${taskId} stopped.` : `Failed to stop task ${taskId} (not found or already stopped).`
|
|
123
|
+
}]
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
server.registerTool(
|
|
129
|
+
'ListTasks',
|
|
130
|
+
{
|
|
131
|
+
title: 'List Tasks',
|
|
132
|
+
description: LIST_TASKS_DESCRIPTION,
|
|
133
|
+
inputSchema: z.object({})
|
|
134
|
+
},
|
|
135
|
+
async () => {
|
|
136
|
+
const tasks = taskManager.getAllTasks()
|
|
137
|
+
return {
|
|
138
|
+
content: [{
|
|
139
|
+
type: 'text',
|
|
140
|
+
text: JSON.stringify(tasks.map(task => serializeTaskInfo({ taskId: task.taskId, info: task })))
|
|
141
|
+
}]
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
}
|