@yeaft/webchat-agent 1.0.216 → 1.0.218
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/local-runtime/version.json +1 -1
- package/package.json +1 -1
- package/yeaft/conversation/search.js +100 -37
- package/yeaft/tools/history-search.js +152 -16
- package/yeaft/work-center/controller.js +65 -3
- package/yeaft/work-center/plan-mutation.js +120 -0
- package/yeaft/work-center/runner.js +83 -9
- package/yeaft/work-center/store.js +75 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.218"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* search.js — Conversation history search
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Bounded content search across hot and cold messages.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
@@ -18,36 +18,77 @@ function parseJsonLine(line) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function normalizeTerms(keyword) {
|
|
22
|
+
return String(keyword || '')
|
|
23
|
+
.trim()
|
|
24
|
+
.toLocaleLowerCase()
|
|
25
|
+
.split(/\s+/u)
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function searchableContent(msg) {
|
|
30
|
+
if (typeof msg?.content === 'string') return msg.content;
|
|
31
|
+
if (Array.isArray(msg?.content)) {
|
|
32
|
+
return msg.content
|
|
33
|
+
.map(block => typeof block === 'string' ? block : (block?.text || ''))
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join('\n');
|
|
36
|
+
}
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function matchesMessage(msg, terms) {
|
|
41
|
+
if (!msg || msg.role === 'tool') return false;
|
|
42
|
+
const content = searchableContent(msg).toLocaleLowerCase();
|
|
43
|
+
return content.length > 0 && terms.every(term => content.includes(term));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function recordFileScan(telemetry, raw) {
|
|
47
|
+
if (!telemetry) return;
|
|
48
|
+
telemetry.scannedFiles += 1;
|
|
49
|
+
telemetry.scannedBytes += Buffer.byteLength(raw, 'utf8');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function recordMessageScan(telemetry) {
|
|
53
|
+
if (telemetry) telemetry.scannedMessages += 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function withSource(msg, source) {
|
|
57
|
+
return {
|
|
58
|
+
...msg,
|
|
59
|
+
content: searchableContent(msg),
|
|
60
|
+
sessionId: msg.sessionId || source.sessionId || null,
|
|
61
|
+
historySource: source.kind,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
21
65
|
/**
|
|
22
|
-
* Search Markdown messages in
|
|
23
|
-
*
|
|
24
|
-
* @param {string} dir — messages directory
|
|
25
|
-
* @param {string} keyword — search term (case-insensitive)
|
|
26
|
-
* @returns {object[]} — matching messages
|
|
66
|
+
* Search Markdown messages in one directory, newest first.
|
|
27
67
|
*/
|
|
28
|
-
function searchMarkdownDir(dir,
|
|
68
|
+
function searchMarkdownDir(dir, terms, limit, source, telemetry) {
|
|
29
69
|
if (!existsSync(dir)) return [];
|
|
30
70
|
|
|
31
|
-
const lowerKeyword = keyword.toLowerCase();
|
|
32
71
|
const files = readdirSync(dir)
|
|
33
72
|
.filter(f => f.endsWith('.md'))
|
|
34
73
|
.sort()
|
|
35
|
-
.reverse();
|
|
74
|
+
.reverse();
|
|
36
75
|
|
|
37
76
|
const results = [];
|
|
38
77
|
for (const file of files) {
|
|
78
|
+
if (results.length >= limit) break;
|
|
39
79
|
const raw = readFileSync(join(dir, file), 'utf8');
|
|
40
|
-
|
|
41
|
-
|
|
80
|
+
recordFileScan(telemetry, raw);
|
|
81
|
+
recordMessageScan(telemetry);
|
|
82
|
+
const lowerRaw = raw.toLocaleLowerCase();
|
|
83
|
+
if (!terms.every(term => lowerRaw.includes(term))) continue;
|
|
42
84
|
const msg = parseMessage(raw);
|
|
43
|
-
if (msg) results.push(msg);
|
|
85
|
+
if (matchesMessage(msg, terms)) results.push(withSource(msg, source));
|
|
44
86
|
}
|
|
45
87
|
return results;
|
|
46
88
|
}
|
|
47
89
|
|
|
48
|
-
function searchSegmentDir(dir,
|
|
90
|
+
function searchSegmentDir(dir, terms, limit, source, telemetry) {
|
|
49
91
|
if (!existsSync(dir)) return [];
|
|
50
|
-
const lowerKeyword = keyword.toLowerCase();
|
|
51
92
|
const files = readdirSync(dir)
|
|
52
93
|
.filter(f => f.endsWith('.jsonl'))
|
|
53
94
|
.sort()
|
|
@@ -55,24 +96,30 @@ function searchSegmentDir(dir, keyword) {
|
|
|
55
96
|
|
|
56
97
|
const results = [];
|
|
57
98
|
for (const file of files) {
|
|
99
|
+
if (results.length >= limit) break;
|
|
58
100
|
const raw = readFileSync(join(dir, file), 'utf8');
|
|
59
|
-
|
|
101
|
+
recordFileScan(telemetry, raw);
|
|
102
|
+
const lowerRaw = raw.toLocaleLowerCase();
|
|
103
|
+
if (!terms.every(term => lowerRaw.includes(term))) continue;
|
|
60
104
|
const lines = raw.split('\n');
|
|
61
105
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
62
|
-
|
|
63
|
-
if (!
|
|
64
|
-
|
|
65
|
-
|
|
106
|
+
if (results.length >= limit) break;
|
|
107
|
+
if (!lines[i]?.trim()) continue;
|
|
108
|
+
recordMessageScan(telemetry);
|
|
109
|
+
const msg = parseJsonLine(lines[i]);
|
|
110
|
+
if (matchesMessage(msg, terms)) results.push(withSource(msg, source));
|
|
66
111
|
}
|
|
67
112
|
}
|
|
68
113
|
return results;
|
|
69
114
|
}
|
|
70
115
|
|
|
71
116
|
function compareNewest(a, b) {
|
|
117
|
+
const timeComparison = String(b?.time || b?.timestamp || '').localeCompare(String(a?.time || a?.timestamp || ''));
|
|
118
|
+
if (timeComparison !== 0) return timeComparison;
|
|
119
|
+
if (a?.sessionId !== b?.sessionId || a?.historySource !== b?.historySource) return 0;
|
|
72
120
|
const sa = parseSeqFromId(a?.id);
|
|
73
121
|
const sb = parseSeqFromId(b?.id);
|
|
74
|
-
|
|
75
|
-
return String(b?.time || '').localeCompare(String(a?.time || ''));
|
|
122
|
+
return Number.isFinite(sa) && Number.isFinite(sb) ? sb - sa : 0;
|
|
76
123
|
}
|
|
77
124
|
|
|
78
125
|
function sessionConversationDirs(dir) {
|
|
@@ -92,40 +139,56 @@ function sessionConversationDirs(dir) {
|
|
|
92
139
|
const conversationDir = join(sessionDir, 'conversation');
|
|
93
140
|
if (seen.has(conversationDir)) continue;
|
|
94
141
|
seen.add(conversationDir);
|
|
95
|
-
dirs.push(conversationDir);
|
|
142
|
+
dirs.push({ dir: conversationDir, sessionId: name, kind: rootName === 'sessions' ? 'session' : 'legacy-session' });
|
|
96
143
|
}
|
|
97
144
|
}
|
|
98
145
|
return dirs;
|
|
99
146
|
}
|
|
100
147
|
|
|
101
148
|
/**
|
|
102
|
-
* Search Yeaft history (chat + per-session + legacy conversation)
|
|
149
|
+
* Search Yeaft history (chat + per-session + legacy conversation) by content.
|
|
150
|
+
* Whitespace-separated terms use AND semantics. Tool messages are excluded.
|
|
103
151
|
*
|
|
104
152
|
* @param {string} dir — Yeaft root directory (e.g. ~/.yeaft)
|
|
105
|
-
* @param {string} keyword — search
|
|
106
|
-
* @param {number} [limit=
|
|
153
|
+
* @param {string} keyword — search terms
|
|
154
|
+
* @param {number} [limit=10] — max results
|
|
155
|
+
* @param {{telemetry?: {scannedFiles?: number, scannedBytes?: number, scannedMessages?: number}}} [options]
|
|
107
156
|
* @returns {object[]} — matching messages, newest first
|
|
108
157
|
*/
|
|
109
|
-
export function searchMessages(dir, keyword, limit =
|
|
110
|
-
|
|
158
|
+
export function searchMessages(dir, keyword, limit = 10, options = {}) {
|
|
159
|
+
const terms = normalizeTerms(keyword);
|
|
160
|
+
if (terms.length === 0) return [];
|
|
161
|
+
|
|
162
|
+
const resultLimit = Math.max(1, Math.min(100, Math.floor(Number(limit) || 10)));
|
|
163
|
+
const telemetry = options.telemetry || null;
|
|
164
|
+
if (telemetry) {
|
|
165
|
+
telemetry.scannedFiles = 0;
|
|
166
|
+
telemetry.scannedBytes = 0;
|
|
167
|
+
telemetry.scannedMessages = 0;
|
|
168
|
+
}
|
|
111
169
|
|
|
112
170
|
const conversationDirs = [
|
|
113
|
-
join(dir, 'chat'),
|
|
171
|
+
{ dir: join(dir, 'chat'), sessionId: null, kind: 'chat' },
|
|
114
172
|
...sessionConversationDirs(dir),
|
|
115
173
|
];
|
|
116
174
|
|
|
117
175
|
const markdownDirs = [
|
|
118
|
-
...conversationDirs.flatMap(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
176
|
+
...conversationDirs.flatMap(source => [
|
|
177
|
+
{ ...source, dir: join(source.dir, 'messages') },
|
|
178
|
+
{ ...source, dir: join(source.dir, 'cold') },
|
|
179
|
+
]),
|
|
180
|
+
{ dir: join(dir, 'conversation', 'messages'), sessionId: null, kind: 'legacy-conversation' },
|
|
181
|
+
{ dir: join(dir, 'conversation', 'cold'), sessionId: null, kind: 'legacy-conversation' },
|
|
122
182
|
];
|
|
123
|
-
const segmentDirs = conversationDirs.map(
|
|
183
|
+
const segmentDirs = conversationDirs.map(source => ({ ...source, dir: join(source.dir, 'segments') }));
|
|
124
184
|
|
|
125
|
-
|
|
126
|
-
...segmentDirs.flatMap(
|
|
127
|
-
...markdownDirs.flatMap(
|
|
185
|
+
const results = [
|
|
186
|
+
...segmentDirs.flatMap(source => searchSegmentDir(source.dir, terms, resultLimit, source, telemetry)),
|
|
187
|
+
...markdownDirs.flatMap(source => searchMarkdownDir(source.dir, terms, resultLimit, source, telemetry)),
|
|
128
188
|
]
|
|
129
189
|
.sort(compareNewest)
|
|
130
|
-
.slice(0,
|
|
190
|
+
.slice(0, resultLimit);
|
|
191
|
+
|
|
192
|
+
if (telemetry) telemetry.resultCount = results.length;
|
|
193
|
+
return results;
|
|
131
194
|
}
|
|
@@ -8,20 +8,144 @@
|
|
|
8
8
|
import { defineTool } from './types.js';
|
|
9
9
|
import { searchMessages } from '../conversation/search.js';
|
|
10
10
|
|
|
11
|
+
const DEFAULT_RESULT_LIMIT = 10;
|
|
12
|
+
const MAX_SNIPPET_CHARS = 1000;
|
|
13
|
+
export const HISTORY_SEARCH_MAX_OUTPUT_BYTES = 32 * 1024;
|
|
14
|
+
|
|
15
|
+
function truncateUtf8(text, maxBytes) {
|
|
16
|
+
if (maxBytes <= 0) return '';
|
|
17
|
+
const buffer = Buffer.from(String(text), 'utf8');
|
|
18
|
+
if (buffer.length <= maxBytes) return String(text);
|
|
19
|
+
let end = maxBytes;
|
|
20
|
+
while (end > 0 && (buffer[end] & 0xc0) === 0x80) end -= 1;
|
|
21
|
+
return buffer.subarray(0, end).toString('utf8');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isHighSurrogate(codeUnit) {
|
|
25
|
+
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isLowSurrogate(codeUnit) {
|
|
29
|
+
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function lowercaseWithOriginalOffsets(text) {
|
|
33
|
+
const fullLower = text.toLocaleLowerCase();
|
|
34
|
+
const lowerParts = [];
|
|
35
|
+
const originalOffsets = [];
|
|
36
|
+
for (let offset = 0; offset < text.length;) {
|
|
37
|
+
const codePoint = String.fromCodePoint(text.codePointAt(offset));
|
|
38
|
+
const loweredCodePoint = codePoint.toLocaleLowerCase();
|
|
39
|
+
lowerParts.push(loweredCodePoint);
|
|
40
|
+
for (let i = 0; i < loweredCodePoint.length; i += 1) originalOffsets.push(offset);
|
|
41
|
+
offset += codePoint.length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const mappedLower = lowerParts.join('');
|
|
45
|
+
return {
|
|
46
|
+
lower: mappedLower.length === fullLower.length ? fullLower : mappedLower,
|
|
47
|
+
originalOffsets,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildSnippet(content, keyword, maxChars = MAX_SNIPPET_CHARS) {
|
|
52
|
+
const text = String(content || '');
|
|
53
|
+
if (text.length <= maxChars) return text;
|
|
54
|
+
|
|
55
|
+
const terms = String(keyword || '').trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean);
|
|
56
|
+
const { lower, originalOffsets } = lowercaseWithOriginalOffsets(text);
|
|
57
|
+
const positions = terms.map(term => lower.indexOf(term)).filter(pos => pos >= 0);
|
|
58
|
+
const transformedMatchAt = positions.length > 0 ? Math.min(...positions) : 0;
|
|
59
|
+
const matchAt = originalOffsets[transformedMatchAt] ?? 0;
|
|
60
|
+
let start = Math.max(0, Math.min(matchAt - Math.floor(maxChars / 3), text.length - maxChars));
|
|
61
|
+
let end = Math.min(text.length, start + maxChars);
|
|
62
|
+
|
|
63
|
+
if (start > 0 && isLowSurrogate(text.charCodeAt(start)) && isHighSurrogate(text.charCodeAt(start - 1))) {
|
|
64
|
+
start -= 1;
|
|
65
|
+
}
|
|
66
|
+
if (end < text.length && isLowSurrogate(text.charCodeAt(end)) && isHighSurrogate(text.charCodeAt(end - 1))) {
|
|
67
|
+
end -= 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return `${start > 0 ? '...' : ''}${text.slice(start, end)}${end < text.length ? '...' : ''}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function serializeHistorySearchOutput(payload, maxBytes = HISTORY_SEARCH_MAX_OUTPUT_BYTES) {
|
|
74
|
+
const serialize = value => JSON.stringify(value, null, 2);
|
|
75
|
+
let output = serialize(payload);
|
|
76
|
+
if (Buffer.byteLength(output, 'utf8') <= maxBytes) return output;
|
|
77
|
+
|
|
78
|
+
const originalResultCount = payload.results.length;
|
|
79
|
+
const bounded = {
|
|
80
|
+
...payload,
|
|
81
|
+
results: [],
|
|
82
|
+
truncated: true,
|
|
83
|
+
omittedResults: originalResultCount,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
for (const result of payload.results) {
|
|
87
|
+
const candidate = {
|
|
88
|
+
...bounded,
|
|
89
|
+
results: [...bounded.results, result],
|
|
90
|
+
omittedResults: originalResultCount - bounded.results.length - 1,
|
|
91
|
+
};
|
|
92
|
+
const candidateOutput = serialize(candidate);
|
|
93
|
+
if (Buffer.byteLength(candidateOutput, 'utf8') <= maxBytes) {
|
|
94
|
+
bounded.results.push(result);
|
|
95
|
+
bounded.omittedResults -= 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const emptyContentCandidate = {
|
|
100
|
+
...candidate,
|
|
101
|
+
results: [...bounded.results, { ...result, content: '' }],
|
|
102
|
+
};
|
|
103
|
+
const emptyOutput = serialize(emptyContentCandidate);
|
|
104
|
+
if (Buffer.byteLength(emptyOutput, 'utf8') > maxBytes) break;
|
|
105
|
+
|
|
106
|
+
let low = 0;
|
|
107
|
+
let high = Buffer.byteLength(result.content || '', 'utf8');
|
|
108
|
+
let best = '';
|
|
109
|
+
while (low <= high) {
|
|
110
|
+
const mid = Math.floor((low + high) / 2);
|
|
111
|
+
const content = truncateUtf8(result.content || '', mid);
|
|
112
|
+
const partialOutput = serialize({
|
|
113
|
+
...candidate,
|
|
114
|
+
results: [...bounded.results, { ...result, content }],
|
|
115
|
+
});
|
|
116
|
+
if (Buffer.byteLength(partialOutput, 'utf8') <= maxBytes) {
|
|
117
|
+
best = content;
|
|
118
|
+
low = mid + 1;
|
|
119
|
+
} else {
|
|
120
|
+
high = mid - 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
bounded.results.push({ ...result, content: best });
|
|
124
|
+
bounded.omittedResults -= 1;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
output = serialize(bounded);
|
|
129
|
+
if (Buffer.byteLength(output, 'utf8') > maxBytes) {
|
|
130
|
+
throw new Error('History search output metadata exceeds the 32 KiB budget');
|
|
131
|
+
}
|
|
132
|
+
return output;
|
|
133
|
+
}
|
|
134
|
+
|
|
11
135
|
export default defineTool({
|
|
12
136
|
name: 'HistorySearch',
|
|
13
137
|
description: {
|
|
14
138
|
en: `Search through past conversation history.
|
|
15
139
|
|
|
16
|
-
Searches
|
|
17
|
-
Useful for finding previous discussions, decisions, or code snippets.
|
|
140
|
+
Searches message content for all whitespace-separated terms (case-insensitive).
|
|
141
|
+
Tool-result messages are excluded. Useful for finding previous discussions, decisions, or code snippets.
|
|
18
142
|
|
|
19
|
-
Results are returned newest-first with
|
|
143
|
+
Results are returned newest-first with a bounded matching snippet and source metadata.`,
|
|
20
144
|
zh: `搜索历史对话记录。
|
|
21
145
|
|
|
22
|
-
|
|
146
|
+
在已持久化消息的正文中搜索全部空格分隔的关键词(不区分大小写),并排除工具结果消息。用于查找之前的讨论、决策或代码片段。
|
|
23
147
|
|
|
24
|
-
|
|
148
|
+
结果按最新优先返回,包含有界的命中片段和来源信息。`
|
|
25
149
|
},
|
|
26
150
|
parameters: {
|
|
27
151
|
type: 'object',
|
|
@@ -29,15 +153,15 @@ Results are returned newest-first with message role and content.`,
|
|
|
29
153
|
keyword: {
|
|
30
154
|
type: 'string',
|
|
31
155
|
description: {
|
|
32
|
-
en: 'Search
|
|
33
|
-
zh: '
|
|
156
|
+
en: 'Search terms (case-insensitive, whitespace-separated terms use AND semantics)',
|
|
157
|
+
zh: '搜索关键词(不区分大小写,空格分隔的多个词采用 AND 语义)',
|
|
34
158
|
},
|
|
35
159
|
},
|
|
36
160
|
limit: {
|
|
37
161
|
type: 'number',
|
|
38
162
|
description: {
|
|
39
|
-
en: 'Maximum number of results (default:
|
|
40
|
-
zh: '最多返回结果数(默认
|
|
163
|
+
en: 'Maximum number of results (default: 10, maximum: 100)',
|
|
164
|
+
zh: '最多返回结果数(默认 10,最大 100)',
|
|
41
165
|
},
|
|
42
166
|
},
|
|
43
167
|
},
|
|
@@ -46,7 +170,7 @@ Results are returned newest-first with message role and content.`,
|
|
|
46
170
|
isConcurrencySafe: () => true,
|
|
47
171
|
isReadOnly: () => true,
|
|
48
172
|
async execute(input, ctx) {
|
|
49
|
-
const { keyword, limit =
|
|
173
|
+
const { keyword, limit = DEFAULT_RESULT_LIMIT } = input;
|
|
50
174
|
if (!keyword) return JSON.stringify({ error: 'keyword is required' });
|
|
51
175
|
|
|
52
176
|
const yeaftDir = ctx?.yeaftDir;
|
|
@@ -55,25 +179,37 @@ Results are returned newest-first with message role and content.`,
|
|
|
55
179
|
}
|
|
56
180
|
|
|
57
181
|
try {
|
|
58
|
-
const
|
|
182
|
+
const telemetry = {};
|
|
183
|
+
const results = searchMessages(yeaftDir, keyword, limit, { telemetry });
|
|
184
|
+
const searchTelemetry = {
|
|
185
|
+
resultCount: results.length,
|
|
186
|
+
scannedFiles: telemetry.scannedFiles || 0,
|
|
187
|
+
scannedMessages: telemetry.scannedMessages || 0,
|
|
188
|
+
scannedBytes: telemetry.scannedBytes || 0,
|
|
189
|
+
};
|
|
59
190
|
|
|
60
191
|
if (results.length === 0) {
|
|
61
|
-
return
|
|
192
|
+
return serializeHistorySearchOutput({
|
|
62
193
|
results: [],
|
|
63
194
|
message: `No matches found for "${keyword}"`,
|
|
195
|
+
telemetry: searchTelemetry,
|
|
64
196
|
});
|
|
65
197
|
}
|
|
66
198
|
|
|
67
|
-
return
|
|
199
|
+
return serializeHistorySearchOutput({
|
|
68
200
|
results: results.map(msg => ({
|
|
201
|
+
messageId: msg.id || null,
|
|
202
|
+
sessionId: msg.sessionId || null,
|
|
69
203
|
role: msg.role,
|
|
70
|
-
content: msg.content
|
|
204
|
+
content: buildSnippet(msg.content, keyword),
|
|
71
205
|
mode: msg.mode,
|
|
72
|
-
|
|
206
|
+
time: msg.time || msg.timestamp || null,
|
|
207
|
+
source: msg.historySource || null,
|
|
73
208
|
})),
|
|
74
209
|
totalResults: results.length,
|
|
75
210
|
keyword,
|
|
76
|
-
|
|
211
|
+
telemetry: searchTelemetry,
|
|
212
|
+
});
|
|
77
213
|
} catch (err) {
|
|
78
214
|
return JSON.stringify({ error: `History search failed: ${err.message}` });
|
|
79
215
|
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from './workflow.js';
|
|
9
9
|
import { renderSessionContextSnapshot } from './session-context.js';
|
|
10
10
|
import { normalizeEvidence } from './evidence.js';
|
|
11
|
-
import { applyAdditivePlanProposal } from './plan-mutation.js';
|
|
11
|
+
import { applyAdditivePlanProposal, applyReplanMutation } from './plan-mutation.js';
|
|
12
12
|
import { normalizeContractPatch, validateCompletedResult } from './completion-contract.js';
|
|
13
13
|
|
|
14
14
|
function normalizeTerminalResult(result, action) {
|
|
@@ -46,6 +46,8 @@ function normalizeTerminalResult(result, action) {
|
|
|
46
46
|
&& !Array.isArray(result.planProposal) ? result.planProposal : null,
|
|
47
47
|
replanRequest: result.replanRequest && typeof result.replanRequest === 'object'
|
|
48
48
|
&& !Array.isArray(result.replanRequest) ? result.replanRequest : null,
|
|
49
|
+
replanMutation: result.replanMutation && typeof result.replanMutation === 'object'
|
|
50
|
+
&& !Array.isArray(result.replanMutation) ? result.replanMutation : null,
|
|
49
51
|
};
|
|
50
52
|
if (normalized.outcome === 'waiting' && !normalized.waitingReason) {
|
|
51
53
|
throw new Error('waiting outcome requires waitingReason');
|
|
@@ -58,12 +60,14 @@ function normalizeTerminalResult(result, action) {
|
|
|
58
60
|
if (normalized.outcome !== 'completed') {
|
|
59
61
|
normalized.planProposal = null;
|
|
60
62
|
normalized.replanRequest = null;
|
|
63
|
+
normalized.replanMutation = null;
|
|
61
64
|
}
|
|
62
|
-
if (normalized.planProposal
|
|
65
|
+
if ([normalized.planProposal, normalized.replanRequest, normalized.replanMutation].filter(Boolean).length > 1) {
|
|
63
66
|
normalized.outcome = 'failed';
|
|
64
|
-
normalized.error = 'An Action cannot
|
|
67
|
+
normalized.error = 'An Action cannot submit more than one WorkItem plan mutation';
|
|
65
68
|
normalized.planProposal = null;
|
|
66
69
|
normalized.replanRequest = null;
|
|
70
|
+
normalized.replanMutation = null;
|
|
67
71
|
}
|
|
68
72
|
if (action.type === 'review' && normalized.outcome === 'completed' && !normalized.reviewDecision) {
|
|
69
73
|
normalized.outcome = 'failed';
|
|
@@ -323,10 +327,17 @@ export class WorkflowController {
|
|
|
323
327
|
throw new Error('Run has unconsumed Action input and cannot finish yet');
|
|
324
328
|
}
|
|
325
329
|
const result = normalizeTerminalResult(rawResult, activeAction);
|
|
330
|
+
if (result.outcome === 'completed'
|
|
331
|
+
&& activeAction.stageId?.startsWith('replan-')
|
|
332
|
+
&& !result.replanMutation) {
|
|
333
|
+
result.outcome = 'failed';
|
|
334
|
+
result.error = 'Work Center replan triage must submit SubmitWorkItemReplan';
|
|
335
|
+
}
|
|
326
336
|
validateCompletedResult(result, activeAction, activeWorkItem);
|
|
327
337
|
let validatedGeneratedWorkflow = null;
|
|
328
338
|
if (result.outcome === 'completed'
|
|
329
339
|
&& activeAction.type === 'triage'
|
|
340
|
+
&& !activeAction.stageId?.startsWith('replan-')
|
|
330
341
|
&& activeRun
|
|
331
342
|
&& this.store.getWorkItem(activeRun.workItemId)?.workflowSnapshot?.planningMode === 'ai') {
|
|
332
343
|
const current = this.store.getWorkItem(activeRun.workItemId);
|
|
@@ -364,6 +375,27 @@ export class WorkflowController {
|
|
|
364
375
|
result.error = error?.message || String(error);
|
|
365
376
|
}
|
|
366
377
|
}
|
|
378
|
+
let validatedReplanMutation = null;
|
|
379
|
+
let staleReplanMutation = null;
|
|
380
|
+
if (result.outcome === 'completed' && result.replanMutation) {
|
|
381
|
+
const currentWorkItem = this.store.getWorkItem(activeWorkItem.id);
|
|
382
|
+
if (Number(result.replanMutation.basePlanRevision) !== currentWorkItem.planRevision) {
|
|
383
|
+
staleReplanMutation = result.replanMutation;
|
|
384
|
+
} else {
|
|
385
|
+
try {
|
|
386
|
+
validatedReplanMutation = applyReplanMutation({
|
|
387
|
+
workItem: currentWorkItem,
|
|
388
|
+
action: activeAction,
|
|
389
|
+
actions: this.store.getWorkItemDetail(activeWorkItem.id).actions,
|
|
390
|
+
proposal: result.replanMutation,
|
|
391
|
+
availableVpIds: this.listAvailableVpIds?.(),
|
|
392
|
+
});
|
|
393
|
+
} catch (error) {
|
|
394
|
+
result.outcome = 'failed';
|
|
395
|
+
result.error = error?.message || String(error);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
367
399
|
if (result.outcome === 'completed' && result.replanRequest) {
|
|
368
400
|
const basePlanRevision = Number(result.replanRequest.basePlanRevision);
|
|
369
401
|
const proposalId = typeof result.replanRequest.proposalId === 'string'
|
|
@@ -451,6 +483,36 @@ export class WorkflowController {
|
|
|
451
483
|
: effectiveWorkItem;
|
|
452
484
|
const context = [...(action.context || []), contextEntry(action, result, activeRun)];
|
|
453
485
|
if (plannedWorkItem.workflowSnapshot?.executionMode === 'graph') {
|
|
486
|
+
if (staleReplanMutation) {
|
|
487
|
+
return {
|
|
488
|
+
actionStatus: 'completed', workItemStatus: 'needs_attention', graphAdvance: false,
|
|
489
|
+
keepCurrentAction: true,
|
|
490
|
+
planConflict: {
|
|
491
|
+
kind: 'plan_revision',
|
|
492
|
+
proposalId: staleReplanMutation.proposalId,
|
|
493
|
+
expectedPlanRevision: staleReplanMutation.basePlanRevision,
|
|
494
|
+
actualPlanRevision: workItem.planRevision,
|
|
495
|
+
},
|
|
496
|
+
eventType: 'workflow.plan_conflict',
|
|
497
|
+
eventData: { proposalId: staleReplanMutation.proposalId },
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
if (validatedReplanMutation) {
|
|
501
|
+
return {
|
|
502
|
+
actionStatus: 'completed', workItemStatus: 'ready', graphAdvance: true,
|
|
503
|
+
workflowSnapshot: validatedReplanMutation.workflowSnapshot,
|
|
504
|
+
expectedPlanRevision: validatedReplanMutation.basePlanRevision,
|
|
505
|
+
proposalId: validatedReplanMutation.proposalId,
|
|
506
|
+
replanMutation: validatedReplanMutation,
|
|
507
|
+
eventType: 'workflow.replanned',
|
|
508
|
+
eventData: {
|
|
509
|
+
retainedActionCount: validatedReplanMutation.retain.length,
|
|
510
|
+
replacedActionCount: validatedReplanMutation.replace.length,
|
|
511
|
+
removedActionCount: validatedReplanMutation.remove.length,
|
|
512
|
+
addedActionCount: validatedReplanMutation.add.length,
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
}
|
|
454
516
|
if (result.replanRequest) {
|
|
455
517
|
const replanStage = {
|
|
456
518
|
...plannedWorkItem.workflowSnapshot.stages[0],
|
|
@@ -12,6 +12,11 @@ function cleanProposalId(value) {
|
|
|
12
12
|
return id;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function replanBarrierFrom(action) {
|
|
16
|
+
return (Array.isArray(action?.context) ? action.context : [])
|
|
17
|
+
.find(entry => entry?.type === 'replan-barrier') || null;
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
function planActionFromStage(stage) {
|
|
16
21
|
return {
|
|
17
22
|
id: stage.id,
|
|
@@ -183,3 +188,118 @@ export function applyAdditivePlanProposal({ workItem, actions, proposal, availab
|
|
|
183
188
|
dependencyPatches,
|
|
184
189
|
};
|
|
185
190
|
}
|
|
191
|
+
|
|
192
|
+
export function applyReplanMutation({ workItem, action, actions, proposal, availableVpIds = null }) {
|
|
193
|
+
if (workItem.workflowSnapshot?.executionMode !== 'graph'
|
|
194
|
+
|| action?.type !== 'triage'
|
|
195
|
+
|| !action?.stageId?.startsWith('replan-')) {
|
|
196
|
+
throw new Error('Work Center replan mutation requires a replan triage Action');
|
|
197
|
+
}
|
|
198
|
+
if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal)) {
|
|
199
|
+
throw new Error('Work Center replan mutation must be an object');
|
|
200
|
+
}
|
|
201
|
+
const proposalId = cleanProposalId(proposal.proposalId);
|
|
202
|
+
const basePlanRevision = Number(proposal.basePlanRevision);
|
|
203
|
+
if (!Number.isInteger(basePlanRevision) || basePlanRevision !== workItem.planRevision) {
|
|
204
|
+
throw new Error('Work Center replan mutation has a stale basePlanRevision');
|
|
205
|
+
}
|
|
206
|
+
const barrier = replanBarrierFrom(action);
|
|
207
|
+
if (!barrier || !Array.isArray(barrier.candidateActionIds)) {
|
|
208
|
+
throw new Error('Work Center replan Action is missing its frozen candidate set');
|
|
209
|
+
}
|
|
210
|
+
const candidateIds = barrier.candidateActionIds;
|
|
211
|
+
const actionById = new Map(actions.map(candidate => [candidate.id, candidate]));
|
|
212
|
+
const candidates = new Map(candidateIds.map(id => [id, actionById.get(id)]));
|
|
213
|
+
for (const [id, candidate] of candidates) {
|
|
214
|
+
if (!candidate || candidate.status !== 'superseded') {
|
|
215
|
+
throw new Error(`Work Center replan candidate is missing or no longer superseded: ${id}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const classified = new Set();
|
|
220
|
+
const classify = (actionId, kind) => {
|
|
221
|
+
const id = typeof actionId === 'string' ? actionId.trim() : '';
|
|
222
|
+
if (!candidates.has(id)) throw new Error(`Work Center replan ${kind} references a non-candidate Action: ${id || '(missing)'}`);
|
|
223
|
+
if (classified.has(id)) throw new Error(`Work Center replan candidate is classified more than once: ${id}`);
|
|
224
|
+
classified.add(id);
|
|
225
|
+
return candidates.get(id);
|
|
226
|
+
};
|
|
227
|
+
const retained = (Array.isArray(proposal.retain) ? proposal.retain : []).map(entry => ({
|
|
228
|
+
action: classify(entry?.actionId, 'retain'), input: entry?.action,
|
|
229
|
+
}));
|
|
230
|
+
const replaced = (Array.isArray(proposal.replace) ? proposal.replace : []).map(entry => ({
|
|
231
|
+
action: classify(entry?.actionId, 'replace'), input: entry?.action,
|
|
232
|
+
}));
|
|
233
|
+
const removed = (Array.isArray(proposal.remove) ? proposal.remove : []).map(id => classify(id, 'remove'));
|
|
234
|
+
const missing = candidateIds.filter(id => !classified.has(id));
|
|
235
|
+
if (missing.length > 0) throw new Error(`Work Center replan must classify every frozen candidate: ${missing.join(', ')}`);
|
|
236
|
+
|
|
237
|
+
const completed = actions.filter(candidate => candidate.status === 'completed' && candidate.type !== 'triage');
|
|
238
|
+
const currentStages = new Map((workItem.workflowSnapshot.stages || []).map(stage => [stage.id, stage]));
|
|
239
|
+
const historicalStageIds = new Set(actions.map(candidate => candidate.stageId));
|
|
240
|
+
const futureIds = new Set();
|
|
241
|
+
const canonicalFuture = (raw, expectedId = null) => {
|
|
242
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
243
|
+
throw new Error('Work Center replan classification requires a full Action specification');
|
|
244
|
+
}
|
|
245
|
+
const id = canonicalActionId(raw.id);
|
|
246
|
+
if (!id || (expectedId && id !== expectedId)) {
|
|
247
|
+
throw new Error(`Work Center retained Action must keep stage identity: ${expectedId || '(missing)'}`);
|
|
248
|
+
}
|
|
249
|
+
if (futureIds.has(id)) throw new Error(`Work Center replan Action id is duplicated: ${id}`);
|
|
250
|
+
futureIds.add(id);
|
|
251
|
+
return {
|
|
252
|
+
...raw,
|
|
253
|
+
id,
|
|
254
|
+
dependsOnActionIds: canonicalExplicitActionIds(raw.dependsOnActionIds, `Action "${id}" dependencies`),
|
|
255
|
+
changesRequestedActionId: Object.hasOwn(raw, 'changesRequestedActionId')
|
|
256
|
+
? canonicalExplicitActionId(raw.changesRequestedActionId, `Action "${id}" review target`)
|
|
257
|
+
: undefined,
|
|
258
|
+
};
|
|
259
|
+
};
|
|
260
|
+
const retainedInputs = retained.map(entry => canonicalFuture(entry.input, entry.action.stageId));
|
|
261
|
+
const replacementInputs = replaced.map(entry => {
|
|
262
|
+
const input = canonicalFuture(entry.input);
|
|
263
|
+
if (historicalStageIds.has(input.id)) throw new Error(`Work Center replacement Action reuses historical stage identity: ${input.id}`);
|
|
264
|
+
return input;
|
|
265
|
+
});
|
|
266
|
+
const addedInputs = (Array.isArray(proposal.add) ? proposal.add : []).map(raw => {
|
|
267
|
+
const input = canonicalFuture(raw);
|
|
268
|
+
if (historicalStageIds.has(input.id)) throw new Error(`Work Center added Action reuses historical stage identity: ${input.id}`);
|
|
269
|
+
return input;
|
|
270
|
+
});
|
|
271
|
+
const completedInputs = completed.map(candidate => {
|
|
272
|
+
const stage = currentStages.get(candidate.stageId);
|
|
273
|
+
if (!stage) throw new Error(`Work Center completed Action is missing from the frozen workflow: ${candidate.stageId}`);
|
|
274
|
+
return planActionFromStage(stage);
|
|
275
|
+
});
|
|
276
|
+
const synthetic = {
|
|
277
|
+
...workItem,
|
|
278
|
+
workflowSnapshot: { ...workItem.workflowSnapshot, actionTemplates: [], stages: [workItem.workflowSnapshot.stages[0]] },
|
|
279
|
+
};
|
|
280
|
+
const workflowSnapshot = applyGeneratedPlan(synthetic, {
|
|
281
|
+
workItemType: workItem.workflowSnapshot.workItemType,
|
|
282
|
+
actions: stableTopologicalActions([...completedInputs, ...retainedInputs, ...replacementInputs, ...addedInputs]),
|
|
283
|
+
}, { availableVpIds });
|
|
284
|
+
const stageById = new Map(workflowSnapshot.stages.map(stage => [stage.id, stage]));
|
|
285
|
+
const context = (Array.isArray(action.context) ? action.context : [])
|
|
286
|
+
.filter(entry => entry?.type !== 'replan-barrier');
|
|
287
|
+
return {
|
|
288
|
+
proposalId,
|
|
289
|
+
basePlanRevision,
|
|
290
|
+
workflowSnapshot,
|
|
291
|
+
retain: retained.map(entry => ({
|
|
292
|
+
action: entry.action,
|
|
293
|
+
nextAction: actionForStage(stageById.get(entry.action.stageId), { ...workItem, workflowSnapshot }, context),
|
|
294
|
+
})),
|
|
295
|
+
replace: replaced.map((entry, index) => ({
|
|
296
|
+
action: entry.action,
|
|
297
|
+
nextAction: {
|
|
298
|
+
...actionForStage(stageById.get(replacementInputs[index].id), { ...workItem, workflowSnapshot }, context),
|
|
299
|
+
replacesActionId: entry.action.id,
|
|
300
|
+
},
|
|
301
|
+
})),
|
|
302
|
+
add: addedInputs.map(input => actionForStage(stageById.get(input.id), { ...workItem, workflowSnapshot }, context)),
|
|
303
|
+
remove: removed.map(candidate => candidate.id),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
@@ -437,6 +437,52 @@ export function createRequestWorkItemReplanTool({ workItem, collector, isRunActi
|
|
|
437
437
|
});
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
+
export function createSubmitWorkItemReplanTool({ vps, workItem, action, actions, collector, isRunActive }) {
|
|
441
|
+
const vpCatalog = planningVpCatalog(vps);
|
|
442
|
+
const vpIds = vpCatalog.map(vp => vp.id);
|
|
443
|
+
const barrier = (Array.isArray(action.context) ? action.context : [])
|
|
444
|
+
.find(entry => entry?.type === 'replan-barrier');
|
|
445
|
+
const candidateIds = Array.isArray(barrier?.candidateActionIds) ? barrier.candidateActionIds : [];
|
|
446
|
+
const actionById = new Map(actions.map(candidate => [candidate.id, candidate]));
|
|
447
|
+
const candidateSummary = candidateIds.map(id => {
|
|
448
|
+
const candidate = actionById.get(id);
|
|
449
|
+
return `${id}/${candidate?.stageId || 'missing'} (${candidate?.type || 'unknown'})`;
|
|
450
|
+
}).join('; ');
|
|
451
|
+
const candidateIdSchema = candidateIds.length > 0
|
|
452
|
+
? { type: 'string', enum: candidateIds }
|
|
453
|
+
: { type: 'string' };
|
|
454
|
+
const candidateLimit = Math.min(8, candidateIds.length);
|
|
455
|
+
const classification = { type: 'object', additionalProperties: false,
|
|
456
|
+
required: ['actionId', 'action'], properties: {
|
|
457
|
+
actionId: candidateIdSchema,
|
|
458
|
+
action: plannedActionSchema(vpIds),
|
|
459
|
+
} };
|
|
460
|
+
return defineTool({
|
|
461
|
+
name: 'SubmitWorkItemReplan',
|
|
462
|
+
description: `Submit the complete replacement topology after a replan barrier. Classify every frozen candidate exactly once as retain, replace, or remove. Retain keeps its database Action identity and stage id but requires the complete updated specification. Replace creates a new Action linked to the old database Action. Add is only for new work. Frozen candidates: ${candidateSummary}. Available VPs: ${vpCatalog.map(vp => vp.id).join(', ')}.`,
|
|
463
|
+
parameters: { type: 'object', additionalProperties: false,
|
|
464
|
+
required: ['summary', 'evidence', 'acceptanceChecks', 'proposalId', 'basePlanRevision', 'retain', 'replace', 'remove', 'add'],
|
|
465
|
+
properties: {
|
|
466
|
+
...terminalPlanningFields(),
|
|
467
|
+
proposalId: { type: 'string', minLength: 1, maxLength: 128 },
|
|
468
|
+
basePlanRevision: { type: 'integer', const: workItem.planRevision },
|
|
469
|
+
retain: { type: 'array', maxItems: candidateLimit, items: classification },
|
|
470
|
+
replace: { type: 'array', maxItems: candidateLimit, items: classification },
|
|
471
|
+
remove: { type: 'array', maxItems: candidateLimit, uniqueItems: true, items: candidateIdSchema },
|
|
472
|
+
add: { type: 'array', maxItems: 8, items: plannedActionSchema(vpIds) },
|
|
473
|
+
} },
|
|
474
|
+
async execute(input, ctx = {}) {
|
|
475
|
+
if (!isRunActive()) throw new Error('Work Center Run is no longer active');
|
|
476
|
+
if (collector.value) throw new Error('A WorkItem plan was already submitted for this Run');
|
|
477
|
+
collector.value = structuredClone(input);
|
|
478
|
+
ctx.requestEndTurn?.({ kind: 'work_item_replan_submitted', proposalId: input.proposalId });
|
|
479
|
+
return JSON.stringify({ submitted: true, proposalId: input.proposalId });
|
|
480
|
+
},
|
|
481
|
+
isConcurrencySafe: () => false,
|
|
482
|
+
isReadOnly: () => false,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
440
486
|
export function createWorkItemToolRegistry({ workDir, attachmentFiles = [], isRunActive, mcpTools = [], runTools = [] }) {
|
|
441
487
|
const canonicalDir = canonicalWorkDir(path.resolve(workDir));
|
|
442
488
|
const canonicalAttachmentFiles = attachmentFiles.map(file => ({
|
|
@@ -504,11 +550,15 @@ function completionContract(action, workItem) {
|
|
|
504
550
|
const triageField = action.type === 'triage'
|
|
505
551
|
? ',\n "contractPatch": { "goal": "optional refined goal", "acceptanceCriteria": ["optional refined criterion"] }'
|
|
506
552
|
: '';
|
|
507
|
-
const planField = action.type === 'triage'
|
|
553
|
+
const planField = action.type === 'triage'
|
|
554
|
+
&& !action.stageId?.startsWith('replan-')
|
|
555
|
+
&& workItem?.workflowSnapshot?.planningMode === 'ai'
|
|
508
556
|
? ',\n "plan": { "workItemType": "specific-lowercase-slug", "actions": [{ "id": "stable-id", "name": "User-facing name", "type": "extensible-lowercase-slug (built-ins include research|design|diagnose|implement|migrate|test|review|document|operate|deliver|integrate|write|custom)", "capability": "specific executor capability", "objective": "task-specific concrete work this Action must do", "approach": "task-specific repository-aware method the executor must follow", "expectedOutcome": "task-specific verifiable result this Action must produce", "dependsOnActionIds": ["earlier Action id; [] means concurrent root"], "workspaceMode": "read|isolated-write|integrate|shared", "separateFromActionTypes": ["optional prior Action type"], "changesRequestedActionId": "for review: optional earlier editable Action id; omit to use nearest", "maxAttempts": 2 }] }'
|
|
509
557
|
: '';
|
|
510
|
-
const toolSubmission = action.type === 'triage' &&
|
|
511
|
-
? '\nSubmit the
|
|
558
|
+
const toolSubmission = action.type === 'triage' && action.stageId?.startsWith('replan-')
|
|
559
|
+
? '\nSubmit the replan only with SubmitWorkItemReplan. Classify every frozen candidate exactly once; do not emit terminal JSON after calling it.'
|
|
560
|
+
: action.type === 'triage' && workItem?.workflowSnapshot?.planningMode === 'ai'
|
|
561
|
+
? '\nSubmit the initial plan with SubmitWorkItemPlan. The legacy terminal JSON plan below exists only for compatibility; do not use it when the tool is available.'
|
|
512
562
|
: workItem?.workflowSnapshot?.executionMode === 'graph'
|
|
513
563
|
? '\nIf execution discovered strictly additive work, use ProposeWorkItemActions. If the contract or existing unfinished topology must change, use RequestWorkItemReplan. Both tools submit the completed Action and end the turn; do not emit terminal JSON after calling one.'
|
|
514
564
|
: '';
|
|
@@ -901,19 +951,29 @@ export class WorkItemRunner {
|
|
|
901
951
|
const mcpToolNames = workspaceRuntime.mcpTools.map(tool => tool.name);
|
|
902
952
|
const planCollector = { value: null };
|
|
903
953
|
const mutationCollector = { value: null };
|
|
954
|
+
const replanToolEnabled = executionAction.type === 'triage'
|
|
955
|
+
&& executionAction.stageId?.startsWith('replan-');
|
|
904
956
|
const planToolEnabled = executionAction.type === 'triage'
|
|
905
|
-
&& workItem?.workflowSnapshot?.planningMode === 'ai'
|
|
957
|
+
&& workItem?.workflowSnapshot?.planningMode === 'ai'
|
|
958
|
+
&& !replanToolEnabled;
|
|
906
959
|
const runTools = [];
|
|
907
960
|
if (planToolEnabled) runTools.push(createSubmitWorkItemPlanTool({
|
|
908
961
|
vps: this.registry.listVps(),
|
|
909
962
|
workItem,
|
|
910
963
|
collector: planCollector,
|
|
911
964
|
isRunActive,
|
|
912
|
-
reservedStageIds:
|
|
913
|
-
|
|
914
|
-
|
|
965
|
+
reservedStageIds: [],
|
|
966
|
+
}));
|
|
967
|
+
if (replanToolEnabled) runTools.push(createSubmitWorkItemReplanTool({
|
|
968
|
+
vps: this.registry.listVps(),
|
|
969
|
+
workItem,
|
|
970
|
+
action: executionAction,
|
|
971
|
+
actions: this.store.getWorkItemDetail(workItem.id).actions,
|
|
972
|
+
collector: planCollector,
|
|
973
|
+
isRunActive,
|
|
915
974
|
}));
|
|
916
|
-
if (!planToolEnabled &&
|
|
975
|
+
if (!planToolEnabled && !replanToolEnabled
|
|
976
|
+
&& workItem?.workflowSnapshot?.executionMode === 'graph') {
|
|
917
977
|
runTools.push(createProposeWorkItemActionsTool({
|
|
918
978
|
vps: this.registry.listVps(), workItem,
|
|
919
979
|
actions: this.store.getWorkItemDetail(workItem.id).actions,
|
|
@@ -1125,7 +1185,8 @@ export class WorkItemRunner {
|
|
|
1125
1185
|
}
|
|
1126
1186
|
const response = publicWorkItemResponse(text);
|
|
1127
1187
|
reportProgress(true);
|
|
1128
|
-
const submittedPlan = planCollector.value;
|
|
1188
|
+
const submittedPlan = !replanToolEnabled ? planCollector.value : null;
|
|
1189
|
+
const submittedReplanMutation = replanToolEnabled ? planCollector.value : null;
|
|
1129
1190
|
const submittedExpansion = mutationCollector.value?.kind === 'expand'
|
|
1130
1191
|
? mutationCollector.value.input : null;
|
|
1131
1192
|
const submittedReplan = mutationCollector.value?.kind === 'replan'
|
|
@@ -1137,6 +1198,19 @@ export class WorkItemRunner {
|
|
|
1137
1198
|
contractPatch: submittedPlan.contractPatch || null,
|
|
1138
1199
|
plan: { workItemType: submittedPlan.workItemType, actions: submittedPlan.actions },
|
|
1139
1200
|
acceptanceChecks: submittedPlan.acceptanceChecks,
|
|
1201
|
+
} : submittedReplanMutation ? {
|
|
1202
|
+
outcome: 'completed',
|
|
1203
|
+
summary: submittedReplanMutation.summary,
|
|
1204
|
+
evidence: submittedReplanMutation.evidence,
|
|
1205
|
+
acceptanceChecks: submittedReplanMutation.acceptanceChecks,
|
|
1206
|
+
replanMutation: {
|
|
1207
|
+
proposalId: submittedReplanMutation.proposalId,
|
|
1208
|
+
basePlanRevision: submittedReplanMutation.basePlanRevision,
|
|
1209
|
+
retain: submittedReplanMutation.retain,
|
|
1210
|
+
replace: submittedReplanMutation.replace,
|
|
1211
|
+
remove: submittedReplanMutation.remove,
|
|
1212
|
+
add: submittedReplanMutation.add,
|
|
1213
|
+
},
|
|
1140
1214
|
} : submittedExpansion ? {
|
|
1141
1215
|
outcome: 'completed', summary: submittedExpansion.summary,
|
|
1142
1216
|
evidence: submittedExpansion.evidence, acceptanceChecks: submittedExpansion.acceptanceChecks,
|
|
@@ -2094,6 +2094,15 @@ export class WorkItemStore {
|
|
|
2094
2094
|
throw new Error('Work Center terminal transition lost the current Action fence');
|
|
2095
2095
|
}
|
|
2096
2096
|
|
|
2097
|
+
if (transition.planConflict) {
|
|
2098
|
+
this.db.prepare(`INSERT INTO plan_conflicts
|
|
2099
|
+
(id, work_item_id, action_id, generation, kind, status, details, created_at, updated_at, resolved_at)
|
|
2100
|
+
VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, NULL)`).run(
|
|
2101
|
+
randomUUID(), workItem.id, action.id, action.generation,
|
|
2102
|
+
transition.planConflict.kind || 'plan', stringify(transition.planConflict), now, now,
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2097
2106
|
let nextWorkItem = workItem;
|
|
2098
2107
|
if (transition.contractPatch) {
|
|
2099
2108
|
const patch = transition.contractPatch;
|
|
@@ -2175,10 +2184,66 @@ export class WorkItemStore {
|
|
|
2175
2184
|
nextWorkItem = this.getWorkItem(workItem.id);
|
|
2176
2185
|
nextAction = this.#insertAction(workItem.id, {
|
|
2177
2186
|
...barrier.action,
|
|
2187
|
+
context: [
|
|
2188
|
+
...(Array.isArray(barrier.action.context) ? barrier.action.context : []),
|
|
2189
|
+
{
|
|
2190
|
+
type: 'replan-barrier',
|
|
2191
|
+
proposalId: barrier.proposalId,
|
|
2192
|
+
basePlanRevision: nextWorkItem.planRevision,
|
|
2193
|
+
candidateActionIds: unfinished.map(candidate => candidate.id),
|
|
2194
|
+
},
|
|
2195
|
+
],
|
|
2178
2196
|
contractRevision: nextWorkItem.revision,
|
|
2179
2197
|
status: 'ready',
|
|
2180
2198
|
}, this.#nextSequence(workItem.id), now);
|
|
2181
2199
|
}
|
|
2200
|
+
if (transition.replanMutation) {
|
|
2201
|
+
for (const retained of transition.replanMutation.retain) {
|
|
2202
|
+
const prior = retained.action;
|
|
2203
|
+
const candidate = {
|
|
2204
|
+
...prior,
|
|
2205
|
+
...retained.nextAction,
|
|
2206
|
+
status: 'ready',
|
|
2207
|
+
generation: prior.generation + 1,
|
|
2208
|
+
attempt: 0,
|
|
2209
|
+
currentRunId: null,
|
|
2210
|
+
resultRunId: null,
|
|
2211
|
+
contractRevision: nextWorkItem.revision,
|
|
2212
|
+
};
|
|
2213
|
+
const changed = this.db.prepare(`UPDATE actions SET type = ?, required_role = ?, stage_id = ?,
|
|
2214
|
+
assignment_policy = ?, model_policy = ?, depends_on_stage_ids = ?, workspace_mode = ?,
|
|
2215
|
+
changes_requested_stage_id = ?, workspace = NULL, instruction = ?, brief = ?, context = ?,
|
|
2216
|
+
contract_revision = ?, generation = ?, spec_hash = ?, result_run_id = NULL, status = 'ready',
|
|
2217
|
+
attempt = 0, max_attempts = ?, current_run_id = NULL, lease_epoch = lease_epoch + 1,
|
|
2218
|
+
updated_at = ? WHERE id = ? AND work_item_id = ? AND status = 'superseded' AND generation = ?`).run(
|
|
2219
|
+
candidate.type, candidate.requiredRole || '', candidate.stageId,
|
|
2220
|
+
stringify(candidate.assignmentPolicy || null), stringify(candidate.modelPolicy || null),
|
|
2221
|
+
stringify(candidate.dependsOnStageIds || []), candidate.workspaceMode || 'shared',
|
|
2222
|
+
candidate.changesRequestedStageId || null, candidate.instruction || '', stringify(candidate.brief || null),
|
|
2223
|
+
stringify(candidate.context || []), candidate.contractRevision, candidate.generation,
|
|
2224
|
+
actionSpecHash(candidate), candidate.maxAttempts || 2, now,
|
|
2225
|
+
prior.id, workItem.id, prior.generation,
|
|
2226
|
+
);
|
|
2227
|
+
if (Number(changed.changes) !== 1) throw new Error('Work Center retained Action lost its superseded identity fence');
|
|
2228
|
+
if (!nextAction) nextAction = this.getAction(prior.id);
|
|
2229
|
+
}
|
|
2230
|
+
for (const replacement of transition.replanMutation.replace) {
|
|
2231
|
+
const inserted = this.#insertAction(workItem.id, {
|
|
2232
|
+
...replacement.nextAction,
|
|
2233
|
+
contractRevision: nextWorkItem.revision,
|
|
2234
|
+
status: 'ready',
|
|
2235
|
+
}, this.#nextSequence(workItem.id), now);
|
|
2236
|
+
if (!nextAction) nextAction = inserted;
|
|
2237
|
+
}
|
|
2238
|
+
for (const added of transition.replanMutation.add) {
|
|
2239
|
+
const inserted = this.#insertAction(workItem.id, {
|
|
2240
|
+
...added,
|
|
2241
|
+
contractRevision: nextWorkItem.revision,
|
|
2242
|
+
status: 'ready',
|
|
2243
|
+
}, this.#nextSequence(workItem.id), now);
|
|
2244
|
+
if (!nextAction) nextAction = inserted;
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2182
2247
|
if (transition.graphResetStageId) {
|
|
2183
2248
|
nextAction = this.#resetGraphFromStage(
|
|
2184
2249
|
workItem.id,
|
|
@@ -2205,7 +2270,13 @@ export class WorkItemStore {
|
|
|
2205
2270
|
let workItemStatus = transition.workItemStatus;
|
|
2206
2271
|
let currentActionId = nextAction?.id ?? (transition.keepCurrentAction ? action.id : null);
|
|
2207
2272
|
let changedWorkItem;
|
|
2208
|
-
if (transition.
|
|
2273
|
+
if (transition.planConflict) {
|
|
2274
|
+
changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
|
|
2275
|
+
current_run_id = NULL, ledger_revision = ledger_revision + ?, updated_at = ?
|
|
2276
|
+
WHERE id = ? AND status IN ('ready', 'running', 'waiting', 'needs_attention') AND revision = ?`).run(
|
|
2277
|
+
workItemStatus, currentActionId, ledgerIncrement, now, workItem.id, nextWorkItem.revision,
|
|
2278
|
+
);
|
|
2279
|
+
} else if (transition.graphAdvance) {
|
|
2209
2280
|
const graphState = this.#graphWorkItemState(workItem.id);
|
|
2210
2281
|
workItemStatus = graphState.status;
|
|
2211
2282
|
currentActionId = graphState.currentActionId;
|
|
@@ -2233,7 +2304,9 @@ export class WorkItemStore {
|
|
|
2233
2304
|
(work_item_id, proposal_id, base_plan_revision, plan_revision, kind, action_id, run_id, data, created_at)
|
|
2234
2305
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
2235
2306
|
workItem.id, proposalId, workItem.planRevision, nextWorkItem.planRevision,
|
|
2236
|
-
transition.replanBarrier
|
|
2307
|
+
(transition.replanBarrier || transition.replanMutation)
|
|
2308
|
+
? 'replan'
|
|
2309
|
+
: (workItem.planRevision === 0 ? 'initial' : 'expand'),
|
|
2237
2310
|
action.id, runId, stringify(transition.eventData || {}), now,
|
|
2238
2311
|
);
|
|
2239
2312
|
} catch (error) {
|