Compare commits
1 Commits
v1.13.0-ai
...
v1.13.1-cl
| Author | SHA1 | Date | |
|---|---|---|---|
| a08d809b73 |
@@ -201,6 +201,46 @@ async function main() {
|
|||||||
app.log.info(`serving static frontend from ${webDist}`);
|
app.log.info(`serving static frontend from ${webDist}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.13.3: periodic in-process sweeper for streaming rows orphaned by a
|
||||||
|
// mid-session crash. The boot sweep (above) only fires once at startup;
|
||||||
|
// this loop catches the in-flight case. 60s cadence + 5-min threshold
|
||||||
|
// matches the boot sweep so behavior is consistent. Publishes
|
||||||
|
// chat_status='idle' on the user channel so the UI dot drops without a
|
||||||
|
// refresh — same pattern as handleAbortOrError.
|
||||||
|
const SWEEP_INTERVAL_MS = 60_000;
|
||||||
|
const sweepStaleStreaming = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const rows = await sql<{ id: string; chat_id: string }[]>`
|
||||||
|
UPDATE messages
|
||||||
|
SET status = 'failed', finished_at = clock_timestamp()
|
||||||
|
WHERE status = 'streaming'
|
||||||
|
AND created_at < NOW() - INTERVAL '5 minutes'
|
||||||
|
RETURNING id, chat_id
|
||||||
|
`;
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
app.log.warn(
|
||||||
|
{ swept: rows.length, ids: rows.map((r) => r.id) },
|
||||||
|
'swept stale streaming rows',
|
||||||
|
);
|
||||||
|
const seenChats = new Set<string>();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (seenChats.has(row.chat_id)) continue;
|
||||||
|
seenChats.add(row.chat_id);
|
||||||
|
broker.publishUser('default', {
|
||||||
|
type: 'chat_status',
|
||||||
|
chat_id: row.chat_id,
|
||||||
|
status: 'idle',
|
||||||
|
at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error({ err }, 'stuck-row sweeper failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sweepTimer = setInterval(() => { void sweepStaleStreaming(); }, SWEEP_INTERVAL_MS);
|
||||||
|
app.addHook('onClose', async () => { clearInterval(sweepTimer); });
|
||||||
|
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
app.log.info(`received ${signal}, shutting down`);
|
app.log.info(`received ${signal}, shutting down`);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
-- v1.13.3: statement_timeout is set at database level via:
|
||||||
|
-- ALTER DATABASE boocode SET statement_timeout = '30s';
|
||||||
|
-- ALTER DATABASE can't run inside a DO block, so this is an operational
|
||||||
|
-- step rather than schema. Re-apply after a volume reset (the setting
|
||||||
|
-- lives in pg_db which survives `docker compose up --build` but NOT a
|
||||||
|
-- `docker volume rm boocode_pgdata`).
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
|||||||
14
apps/server/src/services/__tests__/tools.test.ts
Normal file
14
apps/server/src/services/__tests__/tools.test.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ALL_TOOLS } from '../tools.js';
|
||||||
|
|
||||||
|
describe('ALL_TOOLS registry', () => {
|
||||||
|
// v1.13.3: tools must be alpha-sorted at module load. llama.cpp's prompt
|
||||||
|
// cache hits on byte-identical prefixes; the tool list lives near the
|
||||||
|
// top of the system prompt, so any order drift invalidates every cached
|
||||||
|
// turn. The registry sort is the single source of truth; downstream
|
||||||
|
// helpers (toolJsonSchemas, TOOLS_BY_NAME, buildAiTools) inherit it.
|
||||||
|
it('exports tools in alphabetical order by name', () => {
|
||||||
|
const names = ALL_TOOLS.map((t) => t.name);
|
||||||
|
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b)));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,7 +19,14 @@ import type {
|
|||||||
TurnArgs,
|
TurnArgs,
|
||||||
} from './turn.js';
|
} from './turn.js';
|
||||||
import { upstreamModel } from './provider.js';
|
import { upstreamModel } from './provider.js';
|
||||||
import { jsonSchema, streamText, tool, type JSONValue, type ModelMessage } from 'ai';
|
import {
|
||||||
|
jsonSchema,
|
||||||
|
streamText,
|
||||||
|
tool,
|
||||||
|
type JSONValue,
|
||||||
|
type ModelMessage,
|
||||||
|
type ToolCallRepairFunction,
|
||||||
|
} from 'ai';
|
||||||
|
|
||||||
interface StreamOptions {
|
interface StreamOptions {
|
||||||
// null = omit tools entirely (compact phase); [] = caller stripped all tools
|
// null = omit tools entirely (compact phase); [] = caller stripped all tools
|
||||||
@@ -155,10 +162,36 @@ export async function streamCompletion(
|
|||||||
// Replaces the v1.13.1-A counter-only diagnostic.
|
// Replaces the v1.13.1-A counter-only diagnostic.
|
||||||
let reasoningAccumulated = '';
|
let reasoningAccumulated = '';
|
||||||
|
|
||||||
|
// v1.13.3: experimental_repairToolCall keeps the stream alive when the
|
||||||
|
// model emits a malformed tool call (bad JSON args, unknown name, etc.).
|
||||||
|
// Without a repair function streamText throws and the WHOLE stream dies;
|
||||||
|
// with one, the SDK invokes us and we route the bad call through normally.
|
||||||
|
// Strategy: pass through unmodified. executeToolPhase's existing error
|
||||||
|
// path (unknown tool name → "unknown tool: X" result; zod-reject → tool
|
||||||
|
// 'X' rejected — fieldname: required) already gives the model a clean
|
||||||
|
// recovery surface on the next turn. Logging gives us visibility into
|
||||||
|
// how often qwen3.6 actually emits broken calls.
|
||||||
|
const repairToolCall: ToolCallRepairFunction<NonNullable<typeof aiTools>> = async ({
|
||||||
|
toolCall,
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
ctx.log.warn(
|
||||||
|
{
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
toolName: toolCall.toolName,
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
'malformed tool call surfaced via repairToolCall',
|
||||||
|
);
|
||||||
|
return toolCall;
|
||||||
|
};
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: upstreamModel(ctx.config.LLAMA_SWAP_URL, model),
|
model: upstreamModel(ctx.config.LLAMA_SWAP_URL, model),
|
||||||
messages: aiMessages,
|
messages: aiMessages,
|
||||||
...(aiTools ? { tools: aiTools, toolChoice: 'auto' as const } : {}),
|
...(aiTools
|
||||||
|
? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall }
|
||||||
|
: {}),
|
||||||
...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
|
...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
|
||||||
abortSignal: signal,
|
abortSignal: signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -527,6 +527,11 @@ export const askUserInput: ToolDef<AskUserInputInputT> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v1.13.3: alpha-sorted by tool.name at module load. llama.cpp's prompt
|
||||||
|
// cache hits on byte-identical prefixes; the tool list lives near the top
|
||||||
|
// of the system prompt, so any order drift would invalidate every cached
|
||||||
|
// turn. Single source of truth for ordering lives here — toolJsonSchemas()
|
||||||
|
// and TOOLS_BY_NAME inherit it.
|
||||||
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
||||||
viewFile as ToolDef<unknown>,
|
viewFile as ToolDef<unknown>,
|
||||||
listDir as ToolDef<unknown>,
|
listDir as ToolDef<unknown>,
|
||||||
@@ -553,7 +558,7 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
|||||||
watchChanges as ToolDef<unknown>,
|
watchChanges as ToolDef<unknown>,
|
||||||
getSemanticNeighborhoods as ToolDef<unknown>,
|
getSemanticNeighborhoods as ToolDef<unknown>,
|
||||||
getFrameworkAnalysis as ToolDef<unknown>,
|
getFrameworkAnalysis as ToolDef<unknown>,
|
||||||
];
|
].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
||||||
// fully contained in this set gets a generous default tool budget (30);
|
// fully contained in this set gets a generous default tool budget (30);
|
||||||
|
|||||||
Reference in New Issue
Block a user