ruflo 3.6.27 → 3.6.28
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/package.json +1 -1
- package/src/ruvocal/.claude-flow/daemon-state.json +135 -0
- package/src/ruvocal/.claude-flow/data/pending-insights.jsonl +0 -25
- package/src/ruvocal/.claude-flow/data/ranked-context.json +5 -0
- package/src/ruvocal/.claude-flow/logs/daemon.log +31 -0
- package/src/ruvocal/.claude-flow/logs/headless/audit_1777949411822_juxau0_prompt.log +989 -0
- package/src/ruvocal/.claude-flow/logs/headless/audit_1777949411822_juxau0_result.log +67 -0
- package/src/ruvocal/.claude-flow/logs/headless/audit_1777950042278_jvj5xq_prompt.log +989 -0
- package/src/ruvocal/.claude-flow/logs/headless/audit_1777950042278_jvj5xq_result.log +93 -0
- package/src/ruvocal/.claude-flow/logs/headless/optimize_1777949531823_yt5yc2_prompt.log +1498 -0
- package/src/ruvocal/.claude-flow/logs/headless/optimize_1777949531823_yt5yc2_result.log +93 -0
- package/src/ruvocal/.claude-flow/logs/headless/testgaps_1777949771821_elw1j4_prompt.log +1498 -0
- package/src/ruvocal/.claude-flow/logs/headless/testgaps_1777949771821_elw1j4_result.log +100 -0
- package/src/ruvocal/.claude-flow/metrics/codebase-map.json +11 -0
- package/src/ruvocal/.claude-flow/metrics/consolidation.json +6 -0
- package/src/ruvocal/.claude-flow/sessions/current.json +13 -0
- package/src/ruvocal/.swarm/attestation.db +0 -0
- package/src/ruvocal/.swarm/hnsw.index +0 -0
- package/src/ruvocal/.swarm/hnsw.metadata.json +1 -0
- package/src/ruvocal/.swarm/memory.db +0 -0
- package/src/ruvocal/.swarm/schema.sql +305 -0
- package/src/ruvocal/src/lib/components/chat/ChatWindow.svelte +8 -8
- package/src/ruvocal/src/lib/server/mcp/clientPool.spec.ts +175 -0
- package/src/ruvocal/src/lib/server/mcp/clientPool.ts +0 -0
- package/src/ruvocal/src/lib/server/textGeneration/index.ts +1 -0
- package/src/ruvocal/src/lib/server/textGeneration/mcp/runMcpFlow.ts +10 -1
- package/src/ruvocal/src/lib/server/textGeneration/types.ts +3 -1
- package/src/ruvocal/src/routes/api/v2/user/settings/+server.ts +7 -0
- package/src/ruvocal/src/routes/conversation/[id]/+page.svelte +4 -0
- package/src/ruvocal/src/routes/conversation/[id]/+server.ts +4 -0
- package/src/ruvocal/src/routes/settings/(nav)/+server.ts +6 -0
|
@@ -338,9 +338,9 @@
|
|
|
338
338
|
let isFileUploadEnabled = $derived(activeMimeTypes.length > 0);
|
|
339
339
|
let focused = $state(false);
|
|
340
340
|
|
|
341
|
-
let activeRouterExamplePrompt
|
|
341
|
+
let activeRouterExamplePrompt: string | null = $state(null);
|
|
342
342
|
// Use MCP examples when all base servers are enabled, otherwise use router examples
|
|
343
|
-
let activeExamples = $derived
|
|
343
|
+
let activeExamples: RouterExample[] = $derived(
|
|
344
344
|
$allBaseServersEnabled ? mcpExamples : routerExamples
|
|
345
345
|
);
|
|
346
346
|
|
|
@@ -431,7 +431,7 @@
|
|
|
431
431
|
}
|
|
432
432
|
|
|
433
433
|
// Pull tool names from the latest assistant message.
|
|
434
|
-
let lastAssistantToolNames = $derived
|
|
434
|
+
let lastAssistantToolNames: string[] = $derived((() => {
|
|
435
435
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
436
436
|
const msg = messages[i];
|
|
437
437
|
if (msg.from !== "assistant") continue;
|
|
@@ -445,13 +445,13 @@
|
|
|
445
445
|
return names;
|
|
446
446
|
}
|
|
447
447
|
return [];
|
|
448
|
-
}());
|
|
448
|
+
})());
|
|
449
449
|
|
|
450
|
-
let dynamicFollowUps = $derived
|
|
450
|
+
let dynamicFollowUps: RouterFollowUp[] = $derived(
|
|
451
451
|
dedupePrompts(lastAssistantToolNames.flatMap(followUpsForTool), 4)
|
|
452
452
|
);
|
|
453
453
|
|
|
454
|
-
let routerFollowUps = $derived
|
|
454
|
+
let routerFollowUps: RouterFollowUp[] = $derived(
|
|
455
455
|
activeRouterExamplePrompt
|
|
456
456
|
? (activeExamples.find((ex) => ex.prompt === activeRouterExamplePrompt)?.followUps ?? [])
|
|
457
457
|
: []
|
|
@@ -459,7 +459,7 @@
|
|
|
459
459
|
|
|
460
460
|
// Combined: prefer static example follow-ups (curated by us); fall back to
|
|
461
461
|
// dynamic tool-derived follow-ups generated from the last assistant turn.
|
|
462
|
-
let effectiveFollowUps = $derived
|
|
462
|
+
let effectiveFollowUps: RouterFollowUp[] = $derived(
|
|
463
463
|
routerFollowUps.length > 0 ? routerFollowUps : dynamicFollowUps
|
|
464
464
|
);
|
|
465
465
|
|
|
@@ -822,7 +822,7 @@
|
|
|
822
822
|
aria-label="Toggle autopilot mode"
|
|
823
823
|
>
|
|
824
824
|
<IconZap class="size-3.5" />
|
|
825
|
-
<span>{$settings.autopilotEnabled ? 'AUTO' : '
|
|
825
|
+
<span>{$settings.autopilotEnabled ? 'AUTO' : 'MANUAL'}</span>
|
|
826
826
|
</button>
|
|
827
827
|
{/if}
|
|
828
828
|
{#if transcriptionEnabled}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
|
+
|
|
4
|
+
// We mock the MCP SDK transports + Client so we can drive the connection
|
|
5
|
+
// outcomes deterministically. The point of these tests is to verify that
|
|
6
|
+
// clientPool.ts:
|
|
7
|
+
// 1. Skips the SSE fallback when the first transport returns 4xx/5xx (e.g. 429)
|
|
8
|
+
// because retrying via SSE will hit the same upstream and produce the
|
|
9
|
+
// same rate-limit response.
|
|
10
|
+
// 2. Surfaces a typed McpRateLimitedError on 429 with retryAfterMs derived
|
|
11
|
+
// from the upstream Retry-After header when present.
|
|
12
|
+
// 3. Memoizes the failure for a cooldown window so subsequent getClient
|
|
13
|
+
// calls don't pound the upstream.
|
|
14
|
+
// 4. Still falls back to SSE for transport-level / network errors that
|
|
15
|
+
// have no HTTP status (the "Streamable HTTP server is not running" path).
|
|
16
|
+
|
|
17
|
+
const httpConnectMock = vi.fn();
|
|
18
|
+
const sseConnectMock = vi.fn();
|
|
19
|
+
|
|
20
|
+
vi.mock("@modelcontextprotocol/sdk/client", () => {
|
|
21
|
+
class MockClient {
|
|
22
|
+
private nextTransportIsHttp = true;
|
|
23
|
+
async connect(transport: unknown) {
|
|
24
|
+
// The transport instances are tagged below in the streamableHttp/sse mocks.
|
|
25
|
+
const isHttp = (transport as { __kind?: string }).__kind === "http";
|
|
26
|
+
if (isHttp) {
|
|
27
|
+
return httpConnectMock();
|
|
28
|
+
}
|
|
29
|
+
return sseConnectMock();
|
|
30
|
+
}
|
|
31
|
+
async close() {}
|
|
32
|
+
async callTool() {
|
|
33
|
+
return { content: [] };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { Client: MockClient };
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => {
|
|
40
|
+
class StreamableHTTPError extends Error {
|
|
41
|
+
code: number;
|
|
42
|
+
constructor(code: number, message: string) {
|
|
43
|
+
super(`Streamable HTTP error: ${message}`);
|
|
44
|
+
this.code = code;
|
|
45
|
+
this.name = "StreamableHTTPError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
class StreamableHTTPClientTransport {
|
|
49
|
+
__kind = "http";
|
|
50
|
+
constructor(_url: URL, _opts: unknown) {}
|
|
51
|
+
}
|
|
52
|
+
return { StreamableHTTPError, StreamableHTTPClientTransport };
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
vi.mock("@modelcontextprotocol/sdk/client/sse.js", () => {
|
|
56
|
+
class SSEClientTransport {
|
|
57
|
+
__kind = "sse";
|
|
58
|
+
constructor(_url: URL, _opts: unknown) {}
|
|
59
|
+
}
|
|
60
|
+
return { SSEClientTransport };
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Import AFTER vi.mock declarations.
|
|
64
|
+
import { getClient, drainPool, McpRateLimitedError } from "./clientPool";
|
|
65
|
+
|
|
66
|
+
const server = { name: "test-server", url: "https://example.test/mcp", headers: {} };
|
|
67
|
+
|
|
68
|
+
describe("clientPool — rate-limit and HTTP error handling", () => {
|
|
69
|
+
beforeEach(async () => {
|
|
70
|
+
await drainPool();
|
|
71
|
+
httpConnectMock.mockReset();
|
|
72
|
+
sseConnectMock.mockReset();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(async () => {
|
|
76
|
+
await drainPool();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("skips SSE fallback on 429 and throws McpRateLimitedError", async () => {
|
|
80
|
+
httpConnectMock.mockRejectedValue(
|
|
81
|
+
new StreamableHTTPError(
|
|
82
|
+
429,
|
|
83
|
+
"Error POSTing to endpoint: Rate exceeded. Retry-After: 7"
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
await expect(getClient(server)).rejects.toBeInstanceOf(McpRateLimitedError);
|
|
88
|
+
// Critical: SSE was NOT attempted — would just hit the same upstream.
|
|
89
|
+
expect(sseConnectMock).not.toHaveBeenCalled();
|
|
90
|
+
expect(httpConnectMock).toHaveBeenCalledTimes(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("honors Retry-After when present in the error message", async () => {
|
|
94
|
+
httpConnectMock.mockRejectedValue(
|
|
95
|
+
new StreamableHTTPError(
|
|
96
|
+
429,
|
|
97
|
+
"Error POSTing to endpoint: Rate exceeded. Retry-After: 12"
|
|
98
|
+
)
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await getClient(server);
|
|
103
|
+
throw new Error("should have thrown");
|
|
104
|
+
} catch (err) {
|
|
105
|
+
expect(err).toBeInstanceOf(McpRateLimitedError);
|
|
106
|
+
expect((err as McpRateLimitedError).retryAfterMs).toBe(12_000);
|
|
107
|
+
expect((err as McpRateLimitedError).status).toBe(429);
|
|
108
|
+
expect((err as McpRateLimitedError).serverName).toBe("test-server");
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("memoizes the 429 failure and skips network on subsequent calls during cooldown", async () => {
|
|
113
|
+
httpConnectMock.mockRejectedValue(
|
|
114
|
+
new StreamableHTTPError(429, "Error POSTing to endpoint: Rate exceeded. Retry-After: 5")
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
await expect(getClient(server)).rejects.toBeInstanceOf(McpRateLimitedError);
|
|
118
|
+
// Second call within the cooldown window: must NOT touch the upstream again.
|
|
119
|
+
await expect(getClient(server)).rejects.toBeInstanceOf(McpRateLimitedError);
|
|
120
|
+
expect(httpConnectMock).toHaveBeenCalledTimes(1);
|
|
121
|
+
expect(sseConnectMock).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("skips SSE fallback on 4xx (e.g. 401)", async () => {
|
|
125
|
+
httpConnectMock.mockRejectedValue(
|
|
126
|
+
new StreamableHTTPError(401, "Unauthorized")
|
|
127
|
+
);
|
|
128
|
+
await expect(getClient(server)).rejects.toThrow(/HTTP 401/);
|
|
129
|
+
expect(sseConnectMock).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("skips SSE fallback on 5xx (e.g. 503)", async () => {
|
|
133
|
+
httpConnectMock.mockRejectedValue(
|
|
134
|
+
new StreamableHTTPError(503, "Service Unavailable")
|
|
135
|
+
);
|
|
136
|
+
await expect(getClient(server)).rejects.toThrow(/HTTP 503/);
|
|
137
|
+
expect(sseConnectMock).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("falls back to SSE on transport-level / network errors with no HTTP status", async () => {
|
|
141
|
+
httpConnectMock.mockRejectedValue(new Error("ECONNREFUSED"));
|
|
142
|
+
sseConnectMock.mockResolvedValue(undefined);
|
|
143
|
+
|
|
144
|
+
const client = await getClient(server);
|
|
145
|
+
expect(client).toBeDefined();
|
|
146
|
+
expect(httpConnectMock).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(sseConnectMock).toHaveBeenCalledTimes(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("falls back to SSE on 408 Request Timeout (recoverable)", async () => {
|
|
151
|
+
httpConnectMock.mockRejectedValue(
|
|
152
|
+
new StreamableHTTPError(408, "Request Timeout")
|
|
153
|
+
);
|
|
154
|
+
sseConnectMock.mockResolvedValue(undefined);
|
|
155
|
+
|
|
156
|
+
const client = await getClient(server);
|
|
157
|
+
expect(client).toBeDefined();
|
|
158
|
+
expect(sseConnectMock).toHaveBeenCalledTimes(1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("surfaces 429 even when the SSE fallback also reports 429", async () => {
|
|
162
|
+
httpConnectMock.mockRejectedValue(new Error("transport mismatch")); // forces SSE attempt
|
|
163
|
+
sseConnectMock.mockRejectedValue(
|
|
164
|
+
new Error("SSE error: Non-200 status code (429)")
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
await getClient(server);
|
|
169
|
+
throw new Error("should have thrown");
|
|
170
|
+
} catch (err) {
|
|
171
|
+
expect(err).toBeInstanceOf(McpRateLimitedError);
|
|
172
|
+
expect((err as McpRateLimitedError).status).toBe(429);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
Binary file
|
|
@@ -51,12 +51,14 @@ export async function* runMcpFlow({
|
|
|
51
51
|
abortController,
|
|
52
52
|
promptedAt,
|
|
53
53
|
autopilot,
|
|
54
|
+
autopilotMaxSteps,
|
|
54
55
|
}: RunMcpFlowContext & {
|
|
55
56
|
preprompt?: string;
|
|
56
57
|
abortSignal?: AbortSignal;
|
|
57
58
|
abortController?: AbortController;
|
|
58
59
|
promptedAt?: Date;
|
|
59
60
|
autopilot?: boolean;
|
|
61
|
+
autopilotMaxSteps?: number;
|
|
60
62
|
}): AsyncGenerator<MessageUpdate, McpFlowResult, undefined> {
|
|
61
63
|
// Helper to check if generation should be aborted via DB polling
|
|
62
64
|
// Also triggers the abort controller to cancel active streams/requests
|
|
@@ -460,7 +462,14 @@ export async function* runMcpFlow({
|
|
|
460
462
|
);
|
|
461
463
|
}
|
|
462
464
|
|
|
463
|
-
|
|
465
|
+
// Autopilot loop cap is user-configurable (Settings.autopilotMaxSteps);
|
|
466
|
+
// fall back to 30 for back-compat when the client doesn't send a value.
|
|
467
|
+
// Non-autopilot mode keeps a tight 10-loop safety net regardless of caller input.
|
|
468
|
+
const autopilotCap =
|
|
469
|
+
typeof autopilotMaxSteps === "number" && autopilotMaxSteps > 0
|
|
470
|
+
? Math.min(autopilotMaxSteps, 100)
|
|
471
|
+
: 30;
|
|
472
|
+
const maxLoops = autopilot ? autopilotCap : 10;
|
|
464
473
|
for (let loop = 0; loop < maxLoops; loop += 1) {
|
|
465
474
|
// Check for abort at the start of each loop iteration
|
|
466
475
|
if (checkAborted()) {
|
|
@@ -21,6 +21,8 @@ export interface TextGenerationContext {
|
|
|
21
21
|
provider?: string;
|
|
22
22
|
locals: App.Locals | undefined;
|
|
23
23
|
abortController: AbortController;
|
|
24
|
-
/** Autopilot mode — auto-continue tool calls up to
|
|
24
|
+
/** Autopilot mode — auto-continue tool calls up to autopilotMaxSteps iterations */
|
|
25
25
|
autopilot?: boolean;
|
|
26
|
+
/** User-configurable cap on autopilot tool-call loops; server falls back to 30 if undefined */
|
|
27
|
+
autopilotMaxSteps?: number;
|
|
26
28
|
}
|
|
@@ -23,6 +23,12 @@ const settingsSchema = z.object({
|
|
|
23
23
|
hapticsEnabled: z.boolean().default(true),
|
|
24
24
|
hidePromptExamples: z.record(z.boolean()).default({}),
|
|
25
25
|
autopilotEnabled: z.boolean().default(true),
|
|
26
|
+
autopilotMaxSteps: z
|
|
27
|
+
.number()
|
|
28
|
+
.int()
|
|
29
|
+
.min(1)
|
|
30
|
+
.max(100)
|
|
31
|
+
.default(DEFAULT_SETTINGS.autopilotMaxSteps),
|
|
26
32
|
billingOrganization: z.string().optional(),
|
|
27
33
|
});
|
|
28
34
|
|
|
@@ -68,6 +74,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|
|
68
74
|
toolsOverrides: settings?.toolsOverrides ?? {},
|
|
69
75
|
providerOverrides: settings?.providerOverrides ?? {},
|
|
70
76
|
autopilotEnabled: settings?.autopilotEnabled ?? DEFAULT_SETTINGS.autopilotEnabled,
|
|
77
|
+
autopilotMaxSteps: settings?.autopilotMaxSteps ?? DEFAULT_SETTINGS.autopilotMaxSteps,
|
|
71
78
|
billingOrganization: settings?.billingOrganization ?? undefined,
|
|
72
79
|
});
|
|
73
80
|
};
|
|
@@ -233,6 +233,10 @@
|
|
|
233
233
|
})),
|
|
234
234
|
streamingMode,
|
|
235
235
|
autopilot: $settings.autopilotEnabled === true,
|
|
236
|
+
autopilotMaxSteps:
|
|
237
|
+
typeof $settings.autopilotMaxSteps === "number"
|
|
238
|
+
? $settings.autopilotMaxSteps
|
|
239
|
+
: undefined,
|
|
236
240
|
},
|
|
237
241
|
messageUpdatesAbortController.signal
|
|
238
242
|
).catch((err) => {
|
|
@@ -130,6 +130,7 @@ export async function POST({ request, locals, params, getClientAddress }) {
|
|
|
130
130
|
selectedMcpServerNames,
|
|
131
131
|
selectedMcpServers,
|
|
132
132
|
autopilot,
|
|
133
|
+
autopilotMaxSteps,
|
|
133
134
|
} = z
|
|
134
135
|
.object({
|
|
135
136
|
id: z.string().uuid().refine(isMessageId).optional(), // parent message id to append to for a normal message, or the message id for a retry/continue
|
|
@@ -141,6 +142,8 @@ export async function POST({ request, locals, params, getClientAddress }) {
|
|
|
141
142
|
),
|
|
142
143
|
is_retry: z.optional(z.boolean()),
|
|
143
144
|
autopilot: z.optional(z.boolean()),
|
|
145
|
+
// User-configurable cap on autopilot tool-call loops. Server clamps to [1, 100].
|
|
146
|
+
autopilotMaxSteps: z.optional(z.number().int().min(1).max(100)),
|
|
144
147
|
selectedMcpServerNames: z.optional(z.array(z.string())),
|
|
145
148
|
selectedMcpServers: z
|
|
146
149
|
.optional(
|
|
@@ -575,6 +578,7 @@ export async function POST({ request, locals, params, getClientAddress }) {
|
|
|
575
578
|
locals,
|
|
576
579
|
abortController: ctrl,
|
|
577
580
|
autopilot: autopilot === true,
|
|
581
|
+
autopilotMaxSteps,
|
|
578
582
|
};
|
|
579
583
|
// run the text generation and send updates to the client
|
|
580
584
|
for await (const event of textGeneration(ctx)) await update(event);
|
|
@@ -19,6 +19,12 @@ const settingsSchema = z.object({
|
|
|
19
19
|
hapticsEnabled: z.boolean().default(true),
|
|
20
20
|
hidePromptExamples: z.record(z.boolean()).default({}),
|
|
21
21
|
autopilotEnabled: z.boolean().default(true),
|
|
22
|
+
autopilotMaxSteps: z
|
|
23
|
+
.number()
|
|
24
|
+
.int()
|
|
25
|
+
.min(1)
|
|
26
|
+
.max(100)
|
|
27
|
+
.default(DEFAULT_SETTINGS.autopilotMaxSteps),
|
|
22
28
|
billingOrganization: z.string().optional(),
|
|
23
29
|
});
|
|
24
30
|
|