feat(page-data): add validation suggestions, repair path, and thinking fixes
Memind CI / Test, build, and release guards (push) Failing after 3m18s
Memind CI / Test, build, and release guards (push) Failing after 3m18s
Map Page Data failure codes to Chinese remediation hints, trigger one-shot goosed repair for remediable cases, and recover poisoned thinking sessions. Route local DeepSeek through the no-think proxy via host.docker.internal so tool rounds no longer hit reasoning_content 400; document gate and case-study scenarios for event registration repair. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { spawn as nodeSpawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -89,14 +90,26 @@ async function* parseSse(body) {
|
||||
}
|
||||
}
|
||||
|
||||
function terminalGoosedEvent(event) {
|
||||
const type = String(
|
||||
event?.event
|
||||
?? event?.data?.type
|
||||
function goosedEventType(event) {
|
||||
// Real Goosed SSE frames use `event: message` (or omit event) and put the
|
||||
// semantic type on `data.type` (e.g. Message / Finish). Prefer data.type so
|
||||
// the generic SSE event name does not mask Finish.
|
||||
const dataType = String(
|
||||
event?.data?.type
|
||||
?? event?.data?.event_type
|
||||
?? '',
|
||||
).trim().toLowerCase();
|
||||
return ['finish', 'finished', 'completed', 'error', 'failed'].includes(type);
|
||||
if (dataType) return dataType;
|
||||
return String(event?.event ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function terminalGoosedEvent(event) {
|
||||
const type = goosedEventType(event);
|
||||
if (['finish', 'finished', 'completed', 'error', 'failed'].includes(type)) {
|
||||
return true;
|
||||
}
|
||||
// Some Goose builds emit `{ type: "Error", error: "..." }` without event name.
|
||||
return Boolean(event?.data?.error) && type === '';
|
||||
}
|
||||
|
||||
export function createGoosedExecutorAdapter({
|
||||
@@ -105,6 +118,8 @@ export function createGoosedExecutorAdapter({
|
||||
fetchImpl = null,
|
||||
allowRemote = false,
|
||||
tlsInsecure = false,
|
||||
provider = '',
|
||||
model = '',
|
||||
workspaceResolver = async (reference) => reference?.id ?? null,
|
||||
} = {}) {
|
||||
const target = normalizeBaseUrl(baseUrl, { allowRemote });
|
||||
@@ -166,6 +181,33 @@ export function createGoosedExecutorAdapter({
|
||||
sessionRef: { kind: 'goosed-session', id: sessionId },
|
||||
});
|
||||
|
||||
const providerId = String(provider ?? '').trim();
|
||||
const modelId = String(model ?? '').trim();
|
||||
if (providerId) {
|
||||
const providerRes = await transport(`${target}/agent/update_provider`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
provider: providerId,
|
||||
...(modelId ? { model: modelId } : {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
...(dispatcher ? { dispatcher } : {}),
|
||||
});
|
||||
if (!providerRes.ok) {
|
||||
throw await responseError(
|
||||
providerRes,
|
||||
'GOOSED_PROVIDER_UPDATE_FAILED',
|
||||
'Goosed provider update failed',
|
||||
);
|
||||
}
|
||||
await emit('executor_job_provider_set', {
|
||||
provider: providerId,
|
||||
model: modelId || null,
|
||||
});
|
||||
}
|
||||
|
||||
const eventsResponse = await transport(
|
||||
`${target}/sessions/${encodeURIComponent(sessionId)}/events`,
|
||||
{
|
||||
@@ -188,9 +230,7 @@ export function createGoosedExecutorAdapter({
|
||||
let finalEvent = null;
|
||||
for await (const event of parseSse(eventsResponse.body)) {
|
||||
eventCount += 1;
|
||||
const eventType = String(
|
||||
event?.event ?? event?.data?.type ?? event?.data?.event_type ?? 'message',
|
||||
).slice(0, 96);
|
||||
const eventType = (goosedEventType(event) || 'message').slice(0, 96);
|
||||
await emit('executor_job_adapter_event', {
|
||||
adapter: 'goosed',
|
||||
eventType,
|
||||
@@ -204,17 +244,26 @@ export function createGoosedExecutorAdapter({
|
||||
return { eventCount, finalEvent };
|
||||
})();
|
||||
|
||||
// Goosed /reply requires created + metadata.userVisible/agentVisible.
|
||||
const userMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
agentVisible: true,
|
||||
},
|
||||
content: [{ type: 'text', text: request.task.instruction }],
|
||||
};
|
||||
const reply = await transport(
|
||||
`${target}/sessions/${encodeURIComponent(sessionId)}/reply`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({
|
||||
request_id: jobId,
|
||||
user_message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: request.task.instruction }],
|
||||
},
|
||||
// Goosed requires a UUID request_id; Executor Job ids are not UUIDs.
|
||||
request_id: crypto.randomUUID(),
|
||||
user_message: userMessage,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
...(dispatcher ? { dispatcher } : {}),
|
||||
@@ -224,14 +273,14 @@ export function createGoosedExecutorAdapter({
|
||||
reply.body?.cancel?.().catch?.(() => {});
|
||||
|
||||
const finished = await finishPromise;
|
||||
const finalType = String(
|
||||
finished.finalEvent?.event
|
||||
?? finished.finalEvent?.data?.type
|
||||
?? finished.finalEvent?.data?.event_type
|
||||
?? '',
|
||||
).toLowerCase();
|
||||
const finalType = goosedEventType(finished.finalEvent);
|
||||
if (['error', 'failed'].includes(finalType)) {
|
||||
throw adapterError('GOOSED_RUN_FAILED', 'Goosed reported a failed terminal event');
|
||||
const detail = String(
|
||||
finished.finalEvent?.data?.error
|
||||
?? finished.finalEvent?.data?.message
|
||||
?? 'Goosed reported a failed terminal event',
|
||||
).slice(0, 500);
|
||||
throw adapterError('GOOSED_RUN_FAILED', detail);
|
||||
}
|
||||
return {
|
||||
outcome: 'completed',
|
||||
@@ -518,5 +567,6 @@ export const executorAdapterInternals = {
|
||||
normalizeBaseUrl,
|
||||
parseSse,
|
||||
resolveWorkspace,
|
||||
goosedEventType,
|
||||
terminalGoosedEvent,
|
||||
};
|
||||
|
||||
@@ -44,12 +44,17 @@ test('Goosed adapter uses the current session API, streams bounded metadata and
|
||||
const adapter = createGoosedExecutorAdapter({
|
||||
baseUrl: 'http://127.0.0.1:18006',
|
||||
secret: 'secret',
|
||||
provider: 'custom_deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
workspaceResolver: async () => '/tmp/canary',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
if (url.endsWith('/agent/start')) {
|
||||
return Response.json({ id: 'session-1' });
|
||||
}
|
||||
if (url.endsWith('/agent/update_provider')) {
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
if (url.endsWith('/events')) {
|
||||
return sseResponse([
|
||||
{ type: 'message', data: { type: 'message', secret: 'not-projected' } },
|
||||
@@ -70,15 +75,27 @@ test('Goosed adapter uses the current session API, streams bounded metadata and
|
||||
assert.equal(calls[0].url, 'http://127.0.0.1:18006/agent/start');
|
||||
assert.equal(calls[0].init.headers['X-Secret-Key'], 'secret');
|
||||
assert.equal(JSON.parse(calls[0].init.body).working_dir, '/tmp/canary');
|
||||
assert.equal(
|
||||
JSON.parse(calls.find((call) => call.url.endsWith('/reply')).init.body)
|
||||
.user_message.content[0].text,
|
||||
'Fix the validation',
|
||||
const providerBody = JSON.parse(
|
||||
calls.find((call) => call.url.endsWith('/agent/update_provider')).init.body,
|
||||
);
|
||||
assert.deepEqual(providerBody, {
|
||||
session_id: 'session-1',
|
||||
provider: 'custom_deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
});
|
||||
const replyBody = JSON.parse(
|
||||
calls.find((call) => call.url.endsWith('/reply')).init.body,
|
||||
);
|
||||
assert.equal(replyBody.user_message.content[0].text, 'Fix the validation');
|
||||
assert.equal(replyBody.user_message.role, 'user');
|
||||
assert.equal(typeof replyBody.user_message.created, 'number');
|
||||
assert.equal(replyBody.user_message.metadata.userVisible, true);
|
||||
assert.equal(replyBody.user_message.metadata.agentVisible, true);
|
||||
assert.deepEqual(
|
||||
emitted.map((event) => event.type),
|
||||
[
|
||||
'executor_job_session_created',
|
||||
'executor_job_provider_set',
|
||||
'executor_job_adapter_event',
|
||||
'executor_job_adapter_event',
|
||||
],
|
||||
@@ -87,6 +104,51 @@ test('Goosed adapter uses the current session API, streams bounded metadata and
|
||||
assert.deepEqual(await adapter.health(), { ok: true, status: 200 });
|
||||
});
|
||||
|
||||
test('Goosed adapter treats data.type Finish as terminal when SSE event is message', async () => {
|
||||
// Real Goosed emits: event: message + data: {"type":"Finish",...}
|
||||
const emitted = [];
|
||||
const adapter = createGoosedExecutorAdapter({
|
||||
baseUrl: 'http://127.0.0.1:18006',
|
||||
secret: 'secret',
|
||||
workspaceResolver: async () => '/tmp/canary',
|
||||
fetchImpl: async (url) => {
|
||||
if (url.endsWith('/agent/start')) {
|
||||
return Response.json({ id: 'session-finish' });
|
||||
}
|
||||
if (url.endsWith('/events')) {
|
||||
return sseResponse([
|
||||
{
|
||||
type: 'message',
|
||||
data: {
|
||||
type: 'Message',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
data: { type: 'Finish', reason: 'stop', request_id: 'req-1' },
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (url.endsWith('/reply')) return Response.json({ ok: true });
|
||||
if (url.endsWith('/status')) return Response.json({ ok: true });
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
},
|
||||
});
|
||||
const result = await adapter.submit(request(), {
|
||||
jobId: 'job-finish',
|
||||
emit: async (type, data) => emitted.push({ type, data }),
|
||||
});
|
||||
assert.equal(result.outcome, 'completed');
|
||||
assert.equal(result.metrics.eventCount, 2);
|
||||
assert.deepEqual(
|
||||
emitted
|
||||
.filter((event) => event.type === 'executor_job_adapter_event')
|
||||
.map((event) => event.data.eventType),
|
||||
['message', 'finish'],
|
||||
);
|
||||
});
|
||||
|
||||
test('Goosed adapter rejects remote targets unless explicitly allowed', () => {
|
||||
assert.throws(
|
||||
() => createGoosedExecutorAdapter({ baseUrl: 'https://executor.example.com' }),
|
||||
|
||||
@@ -44,6 +44,8 @@ export function createConfiguredWorker({ env = process.env, fetchImpl = null } =
|
||||
...(fetchImpl ? { fetchImpl } : {}),
|
||||
allowRemote: envFlag(env.MEMIND_EXECUTOR_ALLOW_REMOTE_GOOSED, false),
|
||||
tlsInsecure: envFlag(env.MEMIND_EXECUTOR_GOOSED_TLS_INSECURE, false),
|
||||
provider: env.MEMIND_EXECUTOR_GOOSED_PROVIDER,
|
||||
model: env.MEMIND_EXECUTOR_GOOSED_MODEL,
|
||||
workspaceResolver: async (reference) => workspaceAliases[reference?.id] ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user