@myagentroam/node 0.10.20 → 0.10.21
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/dist/capabilities.js +3 -0
- package/dist/connector.js +59 -1
- package/dist/service/model-usage-service.js +29 -1
- package/dist/service/native-session-watch-service.js +10 -0
- package/dist/service/prompt-completion-attention.js +66 -0
- package/dist/service/prompt-completion-cache.js +312 -0
- package/dist/service/prompt-completion-context.js +87 -0
- package/dist/service/prompt-completion-generator.js +404 -0
- package/dist/service/prompt-completion-journal.js +65 -0
- package/dist/service/prompt-completion-node-service.js +241 -0
- package/dist/service/prompt-completion-service.js +311 -0
- package/dist/service/prompt-completion-task-context.js +51 -0
- package/dist/service/prompt-completion-workspace-source.js +62 -0
- package/dist/service/session-catalog-service.js +1 -1
- package/dist/service/workspace-watch-service.js +4 -0
- package/dist/service/workspace-workbench-service.js +6 -0
- package/package.json +4 -4
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { PromptCompletionJournal } from './prompt-completion-journal.js';
|
|
3
|
+
import { promptCompletionContextInputSchema, promptCompletionGenerateInputSchema, promptCompletionRuntimeSchema, promptCompletionCancelSchema, modelUsageGrantSchema } from '@myagentroam/protocol';
|
|
4
|
+
import { PromptCompletionCache } from './prompt-completion-cache.js';
|
|
5
|
+
import { projectCompletionContext } from './prompt-completion-context.js';
|
|
6
|
+
import { withPredictionTaskState } from './prompt-completion-task-context.js';
|
|
7
|
+
import { projectPredictionAttention } from './prompt-completion-attention.js';
|
|
8
|
+
import { PromptCompletionService } from './prompt-completion-service.js';
|
|
9
|
+
/** Owns the auxiliary domain; Connector only supplies existing readers and lifecycle hooks. */
|
|
10
|
+
export class PromptCompletionNodeService {
|
|
11
|
+
options;
|
|
12
|
+
cache;
|
|
13
|
+
journal;
|
|
14
|
+
requests;
|
|
15
|
+
contextReaders = new Map();
|
|
16
|
+
disposed = false;
|
|
17
|
+
refreshTimer;
|
|
18
|
+
generations = new Map();
|
|
19
|
+
now;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.options = options;
|
|
22
|
+
this.now = options.now ?? Date.now;
|
|
23
|
+
this.cache = new PromptCompletionCache({ now: this.now });
|
|
24
|
+
this.requests = new PromptCompletionService({
|
|
25
|
+
record: async (entry) => {
|
|
26
|
+
await this.journal?.record(entry);
|
|
27
|
+
},
|
|
28
|
+
now: this.now,
|
|
29
|
+
modelFactory: options.modelFactory,
|
|
30
|
+
...(options.generate ? { generate: options.generate } : {}),
|
|
31
|
+
authorized: (raw) => {
|
|
32
|
+
const authorization = raw;
|
|
33
|
+
return this.options.observed(authorization.input, authorization.actor);
|
|
34
|
+
},
|
|
35
|
+
context: async (raw, request, signal) => {
|
|
36
|
+
const authorization = raw;
|
|
37
|
+
signal.throwIfAborted();
|
|
38
|
+
const context = await this.readContext(authorization.input, authorization.actor, signal);
|
|
39
|
+
const sessionId = authorization.input.sessionId;
|
|
40
|
+
return {
|
|
41
|
+
...context,
|
|
42
|
+
...(request.attention?.length && this.options.readAttentionFile
|
|
43
|
+
? {
|
|
44
|
+
attention: await projectPredictionAttention(request.attention, (event) => this.options.readAttentionFile(authorization.input, authorization.actor, event), signal, this.now())
|
|
45
|
+
}
|
|
46
|
+
: {}),
|
|
47
|
+
history: sessionId === null
|
|
48
|
+
? context.history
|
|
49
|
+
: this.cache.session(authorization.scope, sessionId, context.identity.historyGeneration, context.history)
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
references: async (raw, _request, signal) => {
|
|
53
|
+
const authorization = raw;
|
|
54
|
+
// The refresh closure captures only authorized resource metadata, never the draft.
|
|
55
|
+
const { actor, input, scope, expiresAt } = authorization;
|
|
56
|
+
const lease = { expiresAt, valid: () => this.options.observed(input, actor) };
|
|
57
|
+
const result = await this.cache.references(scope, input.sessionId, lease, (readSignal) => this.options.references(scope.workspaceId, actor, readSignal));
|
|
58
|
+
signal.throwIfAborted();
|
|
59
|
+
return result.messages;
|
|
60
|
+
},
|
|
61
|
+
usage: (raw) => {
|
|
62
|
+
const authorization = raw;
|
|
63
|
+
return this.options.usage.completion(authorization.executionId, authorization.usageGrant, authorization.input.sessionId ?? authorization.executionId, authorization.actor.userId);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
this.refreshTimer = setInterval(() => {
|
|
67
|
+
this.cache.sweep();
|
|
68
|
+
}, 30_000);
|
|
69
|
+
this.refreshTimer.unref?.();
|
|
70
|
+
}
|
|
71
|
+
operations() {
|
|
72
|
+
return {
|
|
73
|
+
'prompt-completion.context': (raw) => this.context(raw),
|
|
74
|
+
'prompt-completion.generate': (raw) => this.generate(raw),
|
|
75
|
+
'prompt-completion.cancel': (raw) => this.cancel(raw)
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
async context(raw) {
|
|
79
|
+
if (this.disposed)
|
|
80
|
+
throw new Error('PROMPT_COMPLETION_UNAVAILABLE');
|
|
81
|
+
const { actor, data } = unpack(raw);
|
|
82
|
+
const input = promptCompletionContextInputSchema.parse(data);
|
|
83
|
+
if (this.contextReaders.has(actor.userId) || this.contextReaders.size >= 2)
|
|
84
|
+
throw new Error('PROMPT_COMPLETION_CONTEXT_BUSY');
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
this.contextReaders.set(actor.userId, controller);
|
|
87
|
+
const timer = setTimeout(() => controller.abort(), 1_000);
|
|
88
|
+
timer.unref?.();
|
|
89
|
+
const signal = controller.signal;
|
|
90
|
+
const pending = this.readContext(input, actor, signal)
|
|
91
|
+
.then((context) => context.identity)
|
|
92
|
+
.finally(() => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
this.contextReaders.delete(actor.userId);
|
|
95
|
+
});
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const abort = () => reject(new Error('PROMPT_COMPLETION_CONTEXT_TIMEOUT'));
|
|
98
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
99
|
+
pending.then((value) => {
|
|
100
|
+
signal.removeEventListener('abort', abort);
|
|
101
|
+
resolve(value);
|
|
102
|
+
}, (error) => {
|
|
103
|
+
signal.removeEventListener('abort', abort);
|
|
104
|
+
reject(error);
|
|
105
|
+
});
|
|
106
|
+
if (signal.aborted)
|
|
107
|
+
abort();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
generate(raw) {
|
|
111
|
+
const { actor, data, usageGrant } = unpack(raw);
|
|
112
|
+
const { runtime: rawRuntime, ...publicInput } = data;
|
|
113
|
+
const input = promptCompletionGenerateInputSchema.parse(publicInput);
|
|
114
|
+
const runtime = promptCompletionRuntimeSchema.parse(rawRuntime);
|
|
115
|
+
if (runtime.expiresAt > this.now() + 30_000)
|
|
116
|
+
throw new Error('PROMPT_COMPLETION_AUTHORIZATION_INVALID');
|
|
117
|
+
if (input.target.kind === 'WORKSPACE' && input.target.workspaceId !== runtime.workspaceId)
|
|
118
|
+
throw new Error('PROMPT_COMPLETION_AUTHORIZATION_INVALID');
|
|
119
|
+
const descriptor = {
|
|
120
|
+
target: input.target,
|
|
121
|
+
runner: input.request.runner,
|
|
122
|
+
sessionId: input.request.sessionId,
|
|
123
|
+
workbenchConnectionId: input.workbenchConnectionId,
|
|
124
|
+
pageEpoch: input.request.pageEpoch
|
|
125
|
+
};
|
|
126
|
+
const authorization = {
|
|
127
|
+
...runtime,
|
|
128
|
+
usageGrant: modelUsageGrantSchema.parse(usageGrant),
|
|
129
|
+
userId: String(actor.userId),
|
|
130
|
+
actor,
|
|
131
|
+
input: descriptor,
|
|
132
|
+
runtime,
|
|
133
|
+
scope: {
|
|
134
|
+
workspaceId: runtime.workspaceId,
|
|
135
|
+
generation: this.options.generation(),
|
|
136
|
+
authorizationView: digest([runtime.authorizationView, input.target])
|
|
137
|
+
},
|
|
138
|
+
executionId: randomUUID()
|
|
139
|
+
};
|
|
140
|
+
return this.requests.generate({ ...input.request, pageEpoch: epoch(actor, descriptor.pageEpoch) }, authorization);
|
|
141
|
+
}
|
|
142
|
+
cancel(raw) {
|
|
143
|
+
const { actor, data } = unpack(raw);
|
|
144
|
+
const input = promptCompletionCancelSchema.parse(data);
|
|
145
|
+
this.requests.cancel(String(actor.userId), {
|
|
146
|
+
...input,
|
|
147
|
+
pageEpoch: epoch(actor, input.pageEpoch)
|
|
148
|
+
});
|
|
149
|
+
return { cancelled: true };
|
|
150
|
+
}
|
|
151
|
+
async readContext(input, actor, signal) {
|
|
152
|
+
if (!this.options.observed(input, actor))
|
|
153
|
+
throw new Error('PROMPT_COMPLETION_CONTEXT_UNAVAILABLE');
|
|
154
|
+
const generation = this.historyGeneration(input.target, input.sessionId, input.runner);
|
|
155
|
+
const turns = await this.options.readPage(input, actor, signal);
|
|
156
|
+
signal.throwIfAborted();
|
|
157
|
+
if (!this.options.observed(input, actor) ||
|
|
158
|
+
generation !== this.historyGeneration(input.target, input.sessionId, input.runner))
|
|
159
|
+
throw new Error('PROMPT_COMPLETION_CONTEXT_UNAVAILABLE');
|
|
160
|
+
const context = projectCompletionContext(turns, generation);
|
|
161
|
+
return this.options.taskState
|
|
162
|
+
? withPredictionTaskState(context, this.options.taskState(input, actor))
|
|
163
|
+
: context;
|
|
164
|
+
}
|
|
165
|
+
initializeJournal(dataDirectory) {
|
|
166
|
+
this.journal?.dispose();
|
|
167
|
+
this.journal = new PromptCompletionJournal(dataDirectory, this.now);
|
|
168
|
+
}
|
|
169
|
+
sweep() {
|
|
170
|
+
this.cache.sweep();
|
|
171
|
+
}
|
|
172
|
+
disconnect() {
|
|
173
|
+
for (const controller of this.contextReaders.values())
|
|
174
|
+
controller.abort();
|
|
175
|
+
this.requests.disconnect();
|
|
176
|
+
this.cache.clear();
|
|
177
|
+
this.generations.clear();
|
|
178
|
+
}
|
|
179
|
+
invalidateWorkspace(workspaceId) {
|
|
180
|
+
this.cache.invalidateWorkspace(workspaceId);
|
|
181
|
+
this.requests.invalidateWorkspace(workspaceId);
|
|
182
|
+
for (const [key, entry] of this.generations)
|
|
183
|
+
if (entry.target.kind === 'WORKSPACE' && entry.target.workspaceId === workspaceId)
|
|
184
|
+
this.generations.delete(key);
|
|
185
|
+
}
|
|
186
|
+
invalidateUser(userId) {
|
|
187
|
+
this.requests.invalidateUser(String(userId));
|
|
188
|
+
// Permission changes retire all reference views; they can be rebuilt after fresh authorization.
|
|
189
|
+
this.cache.clear();
|
|
190
|
+
}
|
|
191
|
+
invalidateSession(workspaceId, sessionId) {
|
|
192
|
+
// Retire source material without interrupting an already frozen prediction request.
|
|
193
|
+
this.cache.invalidateSession(workspaceId, sessionId);
|
|
194
|
+
}
|
|
195
|
+
releasePage(actor, pageEpoch) {
|
|
196
|
+
this.requests.releasePage(String(actor.userId), epoch(actor, pageEpoch));
|
|
197
|
+
}
|
|
198
|
+
dispose() {
|
|
199
|
+
this.disposed = true;
|
|
200
|
+
clearInterval(this.refreshTimer);
|
|
201
|
+
for (const controller of this.contextReaders.values())
|
|
202
|
+
controller.abort();
|
|
203
|
+
this.requests.dispose();
|
|
204
|
+
this.journal?.dispose();
|
|
205
|
+
this.cache.dispose();
|
|
206
|
+
this.generations.clear();
|
|
207
|
+
}
|
|
208
|
+
historyGeneration(target, sessionId, runner) {
|
|
209
|
+
const key = digest([this.options.generation(), target, sessionId, runner]);
|
|
210
|
+
let entry = this.generations.get(key);
|
|
211
|
+
if (!entry) {
|
|
212
|
+
entry = { generation: randomUUID(), target };
|
|
213
|
+
this.generations.set(key, entry);
|
|
214
|
+
}
|
|
215
|
+
while (this.generations.size > 256)
|
|
216
|
+
this.generations.delete(this.generations.keys().next().value);
|
|
217
|
+
return entry.generation;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function unpack(raw) {
|
|
221
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
222
|
+
throw new Error('MESSAGE_INVALID');
|
|
223
|
+
const { __requestUserId, __workspaceWatchSessionHash, __usageGrant, ...data } = raw;
|
|
224
|
+
if (typeof __requestUserId !== 'number' ||
|
|
225
|
+
!Number.isSafeInteger(__requestUserId) ||
|
|
226
|
+
__requestUserId <= 0 ||
|
|
227
|
+
typeof __workspaceWatchSessionHash !== 'string' ||
|
|
228
|
+
!__workspaceWatchSessionHash)
|
|
229
|
+
throw new Error('USER_AUTHORIZATION_REQUIRED');
|
|
230
|
+
return {
|
|
231
|
+
actor: { userId: __requestUserId, sessionHash: __workspaceWatchSessionHash },
|
|
232
|
+
data,
|
|
233
|
+
usageGrant: __usageGrant
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function epoch(actor, pageEpoch) {
|
|
237
|
+
return digest([actor.sessionHash, pageEpoch]);
|
|
238
|
+
}
|
|
239
|
+
function digest(value) {
|
|
240
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
241
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { generatePromptCompletion } from './prompt-completion-generator.js';
|
|
3
|
+
import { promptCompletionRequestSchema, promptCompletionCancelSchema } from '@myagentroam/protocol';
|
|
4
|
+
/** One manual auxiliary request; it never mutates a Session or enqueues a Run. */
|
|
5
|
+
export class PromptCompletionService {
|
|
6
|
+
options;
|
|
7
|
+
pages = new Map();
|
|
8
|
+
rates = new Map();
|
|
9
|
+
affinities = new Map();
|
|
10
|
+
now;
|
|
11
|
+
disposed = false;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.options = options;
|
|
14
|
+
this.now = options.now ?? Date.now;
|
|
15
|
+
}
|
|
16
|
+
async generate(raw, authorization) {
|
|
17
|
+
const request = promptCompletionRequestSchema.parse(raw);
|
|
18
|
+
const startedAt = this.now();
|
|
19
|
+
const analysis = {};
|
|
20
|
+
let result;
|
|
21
|
+
let failed = false;
|
|
22
|
+
let failureCode;
|
|
23
|
+
try {
|
|
24
|
+
result = await this.execute(request, authorization, analysis);
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
failed = true;
|
|
29
|
+
const code = error?.code;
|
|
30
|
+
failureCode =
|
|
31
|
+
typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,95}$/.test(code)
|
|
32
|
+
? code
|
|
33
|
+
: 'PROMPT_COMPLETION_FAILED';
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
const context = JSON.stringify(analysis);
|
|
38
|
+
try {
|
|
39
|
+
await this.options.record?.({
|
|
40
|
+
schemaVersion: 1,
|
|
41
|
+
startedAt,
|
|
42
|
+
finishedAt: this.now(),
|
|
43
|
+
durationMs: this.now() - startedAt,
|
|
44
|
+
requestId: request.requestId,
|
|
45
|
+
workspaceId: authorization.scope.workspaceId,
|
|
46
|
+
userId: authorization.userId,
|
|
47
|
+
runner: request.runner,
|
|
48
|
+
sessionId: request.sessionId,
|
|
49
|
+
modelId: authorization.model.id,
|
|
50
|
+
providerModelId: authorization.model.modelId,
|
|
51
|
+
reasoningEffort: authorization.reasoningEffort,
|
|
52
|
+
input: {
|
|
53
|
+
trigger: request.trigger,
|
|
54
|
+
draft: request.draft,
|
|
55
|
+
cursorPosition: request.cursorPosition,
|
|
56
|
+
mode: request.completionMode
|
|
57
|
+
},
|
|
58
|
+
context: context.slice(0, 96_000),
|
|
59
|
+
contextTruncated: context.length > 96_000,
|
|
60
|
+
output: result?.status === 'ready' ? result.suggestion : null,
|
|
61
|
+
status: failed ? 'failed' : result?.status,
|
|
62
|
+
reason: failed ? failureCode : result?.status === 'skipped' ? result.reason : undefined
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
console.warn('PROMPT_COMPLETION_JOURNAL_WRITE_FAILED');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async execute(request, authorization, analysis) {
|
|
71
|
+
const identity = responseIdentity(request);
|
|
72
|
+
this.prune();
|
|
73
|
+
if (!this.allowed(authorization))
|
|
74
|
+
return { ...identity, status: 'skipped', reason: 'UNAVAILABLE' };
|
|
75
|
+
const pageKey = JSON.stringify([authorization.userId, request.pageEpoch]);
|
|
76
|
+
const previous = this.pages.get(pageKey);
|
|
77
|
+
if (previous && (previous.closed || request.intentSequence <= previous.sequence))
|
|
78
|
+
return { ...identity, status: 'cancelled' };
|
|
79
|
+
if (previous?.active)
|
|
80
|
+
return { ...identity, status: 'skipped', reason: 'IN_FLIGHT' };
|
|
81
|
+
if (!previous && !this.hasPageCapacity(authorization.userId))
|
|
82
|
+
return { ...identity, status: 'skipped', reason: 'CAPACITY' };
|
|
83
|
+
const active = [...this.pages.values()].filter((page) => page.active);
|
|
84
|
+
if (active.some((page) => page.userId === authorization.userId))
|
|
85
|
+
return { ...identity, status: 'skipped', reason: 'IN_FLIGHT' };
|
|
86
|
+
if (active.length >= 2)
|
|
87
|
+
return { ...identity, status: 'skipped', reason: 'CAPACITY' };
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const page = {
|
|
90
|
+
userId: authorization.userId,
|
|
91
|
+
requestId: request.requestId,
|
|
92
|
+
sequence: request.intentSequence,
|
|
93
|
+
expiresAt: this.now() + 60_000,
|
|
94
|
+
closed: false,
|
|
95
|
+
active: { controller, workspaceId: authorization.scope.workspaceId }
|
|
96
|
+
};
|
|
97
|
+
this.pages.set(pageKey, page);
|
|
98
|
+
const timeout = setTimeout(() => controller.abort(), Math.min(this.options.timeoutMs ?? 12_000, authorization.expiresAt - this.now()));
|
|
99
|
+
const signal = controller.signal;
|
|
100
|
+
// Retain physical capacity until cleanup finishes, even if a slow reader ignores abort.
|
|
101
|
+
const running = (async () => {
|
|
102
|
+
try {
|
|
103
|
+
const context = await this.options.context(authorization, request, signal);
|
|
104
|
+
if (!this.allowed(authorization) || signal.aborted)
|
|
105
|
+
return { ...identity, status: 'cancelled' };
|
|
106
|
+
if (context.identity.historyGeneration !== request.historyGeneration)
|
|
107
|
+
return { ...identity, status: 'cancelled' };
|
|
108
|
+
analysis.context = context;
|
|
109
|
+
const references = await this.options.references(authorization, request, signal);
|
|
110
|
+
analysis.references = references;
|
|
111
|
+
if (!this.allowed(authorization) || signal.aborted)
|
|
112
|
+
return { ...identity, status: 'cancelled' };
|
|
113
|
+
if (!request.draft && context.history.length === 0 && references.length === 0)
|
|
114
|
+
return { ...identity, status: 'empty' };
|
|
115
|
+
const calls = (this.rates.get(authorization.userId) ?? []).filter((time) => time > this.now() - 60_000);
|
|
116
|
+
if (!this.rates.has(authorization.userId) && this.rates.size >= 4096)
|
|
117
|
+
return { ...identity, status: 'skipped', reason: 'CAPACITY' };
|
|
118
|
+
const wait = Math.max(0, 2_000 - (this.now() - (calls.at(-1) ?? -Infinity)), calls.length >= 20 ? 60_000 - (this.now() - calls[0]) : 0);
|
|
119
|
+
if (wait > 0)
|
|
120
|
+
return { ...identity, status: 'skipped', reason: 'RATE_LIMITED', retryAfterMs: wait };
|
|
121
|
+
calls.push(this.now());
|
|
122
|
+
this.rates.set(authorization.userId, calls);
|
|
123
|
+
const [model] = this.options.modelFactory.create([authorization.model], {
|
|
124
|
+
runtimeScopeId: authorization.runtimeScopeId,
|
|
125
|
+
executionId: authorization.executionId
|
|
126
|
+
});
|
|
127
|
+
if (!model)
|
|
128
|
+
throw new Error('PROMPT_COMPLETION_MODEL_UNAVAILABLE');
|
|
129
|
+
let usage;
|
|
130
|
+
let suggestion;
|
|
131
|
+
try {
|
|
132
|
+
usage = this.options.usage(authorization);
|
|
133
|
+
({ suggestion } = await (this.options.generate ?? generatePromptCompletion)({
|
|
134
|
+
model,
|
|
135
|
+
reasoningEffort: authorization.reasoningEffort,
|
|
136
|
+
history: context.history,
|
|
137
|
+
referenceMessages: references,
|
|
138
|
+
executionSnapshot: context.executionSnapshot,
|
|
139
|
+
onPreparedInput: (value) => {
|
|
140
|
+
analysis.preparedInput = value;
|
|
141
|
+
},
|
|
142
|
+
onPredictionOutput: (value) => {
|
|
143
|
+
analysis.modelOutput = value;
|
|
144
|
+
},
|
|
145
|
+
...(context.attention ? { attention: context.attention } : {}),
|
|
146
|
+
draft: request.draft,
|
|
147
|
+
cursorPosition: request.cursorPosition,
|
|
148
|
+
completionMode: request.completionMode,
|
|
149
|
+
promptCacheKey: this.affinity(authorization),
|
|
150
|
+
signal,
|
|
151
|
+
...(usage.onModelUsage ? { onModelUsage: usage.onModelUsage } : {}),
|
|
152
|
+
...(usage.onModelUsageFailure ? { onModelUsageFailure: usage.onModelUsageFailure } : {})
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
try {
|
|
157
|
+
usage?.release();
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
await model.credentialProvider?.close();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!this.allowed(authorization) || signal.aborted)
|
|
164
|
+
return { ...identity, status: 'cancelled' };
|
|
165
|
+
await this.options.validateAuthorization?.(authorization);
|
|
166
|
+
if (!this.allowed(authorization) || signal.aborted)
|
|
167
|
+
return { ...identity, status: 'cancelled' };
|
|
168
|
+
const expiresAt = Math.min(this.now() + 30_000, authorization.expiresAt);
|
|
169
|
+
return suggestion === null
|
|
170
|
+
? { ...identity, status: 'empty' }
|
|
171
|
+
: { ...identity, status: 'ready', suggestion, source: 'generated', expiresAt };
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (signal.aborted)
|
|
175
|
+
return { ...identity, status: 'cancelled' };
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
clearTimeout(timeout);
|
|
180
|
+
delete page.active;
|
|
181
|
+
}
|
|
182
|
+
})();
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
const aborted = () => {
|
|
185
|
+
cleanup();
|
|
186
|
+
resolve({ ...identity, status: 'cancelled' });
|
|
187
|
+
};
|
|
188
|
+
const cleanup = () => signal.removeEventListener('abort', aborted);
|
|
189
|
+
// Transport errors remain rejections; no response body or draft is logged here.
|
|
190
|
+
void running.then((value) => {
|
|
191
|
+
cleanup();
|
|
192
|
+
resolve(value);
|
|
193
|
+
}, (error) => {
|
|
194
|
+
cleanup();
|
|
195
|
+
reject(error);
|
|
196
|
+
});
|
|
197
|
+
if (signal.aborted)
|
|
198
|
+
aborted();
|
|
199
|
+
else
|
|
200
|
+
signal.addEventListener('abort', aborted, { once: true });
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
/** Identity is authenticated separately; cancellation does not require generation rights. */
|
|
204
|
+
cancel(userId, raw) {
|
|
205
|
+
const request = promptCompletionCancelSchema.parse(raw);
|
|
206
|
+
this.prune();
|
|
207
|
+
const key = JSON.stringify([userId, request.pageEpoch]);
|
|
208
|
+
const page = this.pages.get(key);
|
|
209
|
+
if (page && request.intentSequence < page.sequence)
|
|
210
|
+
return;
|
|
211
|
+
if (page && request.intentSequence === page.sequence && page.requestId !== request.requestId)
|
|
212
|
+
throw new Error('PROMPT_COMPLETION_INTENT_CONFLICT');
|
|
213
|
+
if (!page && !this.hasPageCapacity(userId))
|
|
214
|
+
throw new Error('PROMPT_COMPLETION_CAPACITY');
|
|
215
|
+
page?.active?.controller.abort();
|
|
216
|
+
if (page) {
|
|
217
|
+
page.sequence = request.intentSequence;
|
|
218
|
+
page.requestId = request.requestId;
|
|
219
|
+
page.expiresAt = this.now() + 60_000;
|
|
220
|
+
}
|
|
221
|
+
else
|
|
222
|
+
this.pages.set(key, {
|
|
223
|
+
userId,
|
|
224
|
+
requestId: request.requestId,
|
|
225
|
+
sequence: request.intentSequence,
|
|
226
|
+
expiresAt: this.now() + 60_000,
|
|
227
|
+
closed: false
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
invalidateWorkspace(workspaceId) {
|
|
231
|
+
for (const page of this.pages.values())
|
|
232
|
+
if (page.active?.workspaceId === workspaceId)
|
|
233
|
+
page.active.controller.abort();
|
|
234
|
+
this.affinities.clear();
|
|
235
|
+
}
|
|
236
|
+
invalidateUser(userId) {
|
|
237
|
+
for (const [key, page] of this.pages)
|
|
238
|
+
if (JSON.parse(key)[0] === userId)
|
|
239
|
+
page.active?.controller.abort();
|
|
240
|
+
this.affinities.clear();
|
|
241
|
+
}
|
|
242
|
+
releasePage(userId, pageEpoch) {
|
|
243
|
+
const page = this.pages.get(JSON.stringify([userId, pageEpoch]));
|
|
244
|
+
if (!page)
|
|
245
|
+
return;
|
|
246
|
+
page.closed = true;
|
|
247
|
+
page.expiresAt = this.now() + 60_000;
|
|
248
|
+
page.active?.controller.abort();
|
|
249
|
+
}
|
|
250
|
+
disconnect() {
|
|
251
|
+
for (const page of this.pages.values())
|
|
252
|
+
page.active?.controller.abort();
|
|
253
|
+
this.affinities.clear();
|
|
254
|
+
}
|
|
255
|
+
dispose() {
|
|
256
|
+
this.disposed = true;
|
|
257
|
+
for (const page of this.pages.values())
|
|
258
|
+
page.active?.controller.abort();
|
|
259
|
+
this.rates.clear();
|
|
260
|
+
this.affinities.clear();
|
|
261
|
+
this.pages.clear();
|
|
262
|
+
}
|
|
263
|
+
allowed(authorization) {
|
|
264
|
+
return (!this.disposed &&
|
|
265
|
+
authorization.expiresAt > this.now() &&
|
|
266
|
+
this.options.authorized(authorization));
|
|
267
|
+
}
|
|
268
|
+
affinity(authorization) {
|
|
269
|
+
const identity = digest([
|
|
270
|
+
authorization.userId,
|
|
271
|
+
authorization.scope,
|
|
272
|
+
authorization.modelRevision,
|
|
273
|
+
authorization.credentialScope
|
|
274
|
+
]);
|
|
275
|
+
const found = this.affinities.get(identity);
|
|
276
|
+
if (found && found.expiresAt > this.now())
|
|
277
|
+
return found.key;
|
|
278
|
+
const entry = { key: randomUUID(), expiresAt: this.now() + 15 * 60_000 };
|
|
279
|
+
this.affinities.set(identity, entry);
|
|
280
|
+
while (this.affinities.size > 128)
|
|
281
|
+
this.affinities.delete(this.affinities.keys().next().value);
|
|
282
|
+
return entry.key;
|
|
283
|
+
}
|
|
284
|
+
prune() {
|
|
285
|
+
for (const [key, page] of this.pages)
|
|
286
|
+
if (!page.active && page.expiresAt <= this.now())
|
|
287
|
+
this.pages.delete(key);
|
|
288
|
+
for (const [key, value] of this.affinities)
|
|
289
|
+
if (value.expiresAt <= this.now())
|
|
290
|
+
this.affinities.delete(key);
|
|
291
|
+
for (const [key, calls] of this.rates)
|
|
292
|
+
if ((calls.at(-1) ?? 0) <= this.now() - 60_000)
|
|
293
|
+
this.rates.delete(key);
|
|
294
|
+
}
|
|
295
|
+
hasPageCapacity(userId) {
|
|
296
|
+
return (this.pages.size < 4096 &&
|
|
297
|
+
[...this.pages.values()].filter((page) => page.userId === userId).length < 128);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function responseIdentity(request) {
|
|
301
|
+
return {
|
|
302
|
+
requestId: request.requestId,
|
|
303
|
+
contextVersion: request.contextVersion,
|
|
304
|
+
historyGeneration: request.historyGeneration,
|
|
305
|
+
draftRevision: request.draftRevision,
|
|
306
|
+
cursorPosition: request.cursorPosition
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function digest(value) {
|
|
310
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
311
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { composerVisibleText } from '@myagentroam/protocol';
|
|
3
|
+
/** Uses canonical task state only; pending work is the existing Goal/Queue/waiting projection. */
|
|
4
|
+
export function withPredictionTaskState(context, task) {
|
|
5
|
+
const snapshot = JSON.parse(context.executionSnapshot);
|
|
6
|
+
const runStatus = task.runStatus ?? snapshot.status;
|
|
7
|
+
const waiting = snapshot.waiting;
|
|
8
|
+
const phase = waiting.some((item) => item.kind === 'approval')
|
|
9
|
+
? 'WAITING_APPROVAL'
|
|
10
|
+
: waiting.some((item) => item.kind === 'user_input_request')
|
|
11
|
+
? 'WAITING_INPUT'
|
|
12
|
+
: ['RUNNING', 'STARTING', 'QUEUED', 'IN_PROGRESS'].includes(runStatus)
|
|
13
|
+
? 'RUNNING'
|
|
14
|
+
: ['COMPLETED', 'SUCCEEDED'].includes(runStatus)
|
|
15
|
+
? 'COMPLETED'
|
|
16
|
+
: runStatus === 'FAILED'
|
|
17
|
+
? 'FAILED'
|
|
18
|
+
: runStatus === 'IDLE'
|
|
19
|
+
? 'IDLE'
|
|
20
|
+
: 'UNKNOWN';
|
|
21
|
+
const goal = typeof task.goal === 'object' && task.goal !== null
|
|
22
|
+
? { ...task.goal, objective: composerVisibleText(task.goal.objective).slice(0, 2048) }
|
|
23
|
+
: task.goal;
|
|
24
|
+
const queue = task.queue === 'UNKNOWN'
|
|
25
|
+
? 'UNKNOWN'
|
|
26
|
+
: {
|
|
27
|
+
version: task.queue.version,
|
|
28
|
+
paused: task.queue.paused,
|
|
29
|
+
total: task.queue.items.length,
|
|
30
|
+
items: task.queue.items.slice(0, 8).map((item) => ({
|
|
31
|
+
id: item.queueItemId,
|
|
32
|
+
text: composerVisibleText(item.content).slice(0, 768),
|
|
33
|
+
status: 'QUEUED_NOT_EXECUTED'
|
|
34
|
+
}))
|
|
35
|
+
};
|
|
36
|
+
const tasks = { phase, goal, queue, pendingWork: { source: 'GOAL_QUEUE_WAITING', waiting } };
|
|
37
|
+
return {
|
|
38
|
+
...context,
|
|
39
|
+
identity: {
|
|
40
|
+
...context.identity,
|
|
41
|
+
contextVersion: createHash('sha256')
|
|
42
|
+
.update(JSON.stringify([context.identity.contextVersion, tasks]))
|
|
43
|
+
.digest('hex')
|
|
44
|
+
},
|
|
45
|
+
executionSnapshot: JSON.stringify({
|
|
46
|
+
...snapshot,
|
|
47
|
+
status: task.runStatus ?? snapshot.status,
|
|
48
|
+
...tasks
|
|
49
|
+
})
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { projectCompletionContext } from './prompt-completion-context.js';
|
|
2
|
+
/** Uses the existing Workspace catalog and normalized history readers; never scans a second index. */
|
|
3
|
+
export class PromptCompletionWorkspaceSource {
|
|
4
|
+
options;
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.options = options;
|
|
7
|
+
}
|
|
8
|
+
observed(input, actor) {
|
|
9
|
+
if (input.target.kind !== 'WORKSPACE')
|
|
10
|
+
return false;
|
|
11
|
+
return input.sessionId === null
|
|
12
|
+
? this.options.observesWorkspace(input.target.workspaceId, actor, input.workbenchConnectionId)
|
|
13
|
+
: this.options.observesSession(input.target.workspaceId, input.sessionId, actor, input.workbenchConnectionId);
|
|
14
|
+
}
|
|
15
|
+
async readPage(input, actor, signal) {
|
|
16
|
+
if (input.target.kind !== 'WORKSPACE' || !this.observed(input, actor))
|
|
17
|
+
throw new Error('PROMPT_COMPLETION_CONTEXT_UNAVAILABLE');
|
|
18
|
+
this.options.requireWorkspace(input.target.workspaceId);
|
|
19
|
+
signal.throwIfAborted();
|
|
20
|
+
if (input.sessionId === null)
|
|
21
|
+
return [];
|
|
22
|
+
const session = await this.options.resolveSession(input.sessionId);
|
|
23
|
+
if (!session ||
|
|
24
|
+
session.workspaceId !== input.target.workspaceId ||
|
|
25
|
+
session.runner !== input.runner)
|
|
26
|
+
throw new Error('PROMPT_COMPLETION_CONTEXT_UNAVAILABLE');
|
|
27
|
+
signal.throwIfAborted();
|
|
28
|
+
if (!this.observed(input, actor))
|
|
29
|
+
throw new Error('PROMPT_COMPLETION_CONTEXT_UNAVAILABLE');
|
|
30
|
+
const page = await this.options.readHistory(session, 20);
|
|
31
|
+
signal.throwIfAborted();
|
|
32
|
+
if (!this.observed(input, actor))
|
|
33
|
+
throw new Error('PROMPT_COMPLETION_CONTEXT_UNAVAILABLE');
|
|
34
|
+
return page.turns;
|
|
35
|
+
}
|
|
36
|
+
async references(workspaceId, signal) {
|
|
37
|
+
this.options.requireWorkspace(workspaceId);
|
|
38
|
+
signal.throwIfAborted();
|
|
39
|
+
const sessions = this.options
|
|
40
|
+
.cachedSessions(workspaceId)
|
|
41
|
+
.filter((session) => session.workspaceId === workspaceId && session.externalDiscovered !== false)
|
|
42
|
+
.sort((left, right) => right.lastActivityAt - left.lastActivityAt || left.id.localeCompare(right.id))
|
|
43
|
+
.slice(0, 4);
|
|
44
|
+
const messages = [];
|
|
45
|
+
for (const session of sessions) {
|
|
46
|
+
signal.throwIfAborted();
|
|
47
|
+
try {
|
|
48
|
+
const page = await this.options.readHistory(session, 20);
|
|
49
|
+
signal.throwIfAborted();
|
|
50
|
+
const projection = projectCompletionContext(page.turns, 'reference');
|
|
51
|
+
messages.push(...projection.references
|
|
52
|
+
.slice(-20)
|
|
53
|
+
.map((message) => ({ ...message, sessionId: session.id })));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
signal.throwIfAborted();
|
|
57
|
+
// Reference history is optional; an unavailable source does not block the current draft.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return messages;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -142,7 +142,7 @@ export class SessionCatalogService {
|
|
|
142
142
|
? { ...source, lastActivityAt }
|
|
143
143
|
: this.options.runtime.resetAgentSessionHistory(runtimeSession.id, lastActivityAt);
|
|
144
144
|
this.options.projection.remember([session]);
|
|
145
|
-
this.options.invalidateHistory(session.id);
|
|
145
|
+
this.options.invalidateHistory(session.id, session.workspaceId);
|
|
146
146
|
this.options.invalidateWorkspaceSessions(session.workspaceId);
|
|
147
147
|
this.options.emitSession(session);
|
|
148
148
|
return { session: this.options.present(session) };
|