fix(coding-teacher): restore context and read project files
This commit is contained in:
@@ -11,7 +11,8 @@ import {
|
||||
TeacherError,
|
||||
type TeacherAccount,
|
||||
} from './config-client';
|
||||
import type { TeacherModelMessage } from './context';
|
||||
import { estimateTeacherTokens, type TeacherModelMessage, type TeacherToolCall } from './context';
|
||||
import { createTeacherReadTools, type TeacherReadAccess, type TeacherReadTools } from './read-tools';
|
||||
|
||||
interface TeacherModelConfig {
|
||||
api_key: string;
|
||||
@@ -19,7 +20,7 @@ interface TeacherModelConfig {
|
||||
models: string[];
|
||||
model_capabilities_v2: unknown;
|
||||
}
|
||||
export async function prepareTeacherModel(account: TeacherAccount, definition: TeacherDefinition) {
|
||||
export async function prepareTeacherModel(account: TeacherAccount, definition: TeacherDefinition, access?: TeacherReadAccess) {
|
||||
const config = await teacherCloudRequest<TeacherModelConfig>(
|
||||
account,
|
||||
'/api/auth/me/model-config'
|
||||
@@ -67,11 +68,15 @@ export async function prepareTeacherModel(account: TeacherAccount, definition: T
|
||||
capability.limits?.maxInputTokens ?? Infinity,
|
||||
capability.limits?.contextWindow ? capability.limits.contextWindow - outputLimit : Infinity
|
||||
);
|
||||
const tools = access ? createTeacherReadTools(access) : undefined;
|
||||
const toolBudget = tools ? Buffer.byteLength(JSON.stringify(tools.definitions), 'utf8') + 64 : 0;
|
||||
return {
|
||||
inputLimit,
|
||||
// Leave room for a read result and its native tool-call envelope.
|
||||
inputLimit: inputLimit - toolBudget - (tools ? Math.min(2400, Math.floor(inputLimit / 4)) : 0),
|
||||
run: (messages: TeacherModelMessage[], signal: AbortSignal, onText: (text: string) => void) => {
|
||||
assertTeacherAccount(account);
|
||||
return streamTeacherReply(config, modelId, fields, outputLimit, messages, signal, onText);
|
||||
return streamTeacherReply(config, modelId, fields, outputLimit, messages, signal, onText,
|
||||
proxyAwareFetch, { tools, inputLimit, assertCurrent: () => assertTeacherAccount(account) });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -83,8 +88,64 @@ export async function streamTeacherReply(
|
||||
messages: TeacherModelMessage[],
|
||||
signal: AbortSignal,
|
||||
onText: (text: string) => void,
|
||||
fetchImpl: (input: string | URL, init?: RequestInit) => Promise<Response> = proxyAwareFetch
|
||||
fetchImpl: (input: string | URL, init?: RequestInit) => Promise<Response> = proxyAwareFetch,
|
||||
options?: { tools?: TeacherReadTools; inputLimit: number; assertCurrent(): void }
|
||||
): Promise<PublicUsage | undefined> {
|
||||
const tools = options?.tools;
|
||||
const toolBudget = tools ? Buffer.byteLength(JSON.stringify(tools.definitions), 'utf8') + 64 : 0;
|
||||
const reads: TeacherModelMessage[][] = [];
|
||||
let usage: PublicUsage | undefined;
|
||||
// Six read rounds, then one final text response. No recursive agent or Pi session.
|
||||
for (let round = 0; round <= 6; round++) {
|
||||
signal.throwIfAborted();
|
||||
options?.assertCurrent();
|
||||
const build = () => [...messages, ...reads.flat()];
|
||||
while (options && estimateTeacherTokens(build()) + toolBudget > options.inputLimit && reads.length > 1) {
|
||||
reads.shift();
|
||||
}
|
||||
if (options && estimateTeacherTokens(build()) + toolBudget > options.inputLimit) {
|
||||
throw new TeacherError(422, 'teacher_context_too_long', '老师读取的内容超过上下文预算,请缩小问题范围或联系运营增加预算。');
|
||||
}
|
||||
const result = await streamTeacherTurn(config, modelId, reasoningFields, outputLimit,
|
||||
build(), signal, onText, fetchImpl, tools, round === 6);
|
||||
if (result.usage) usage = {
|
||||
inputTokens: (usage?.inputTokens ?? 0) + result.usage.inputTokens,
|
||||
outputTokens: (usage?.outputTokens ?? 0) + result.usage.outputTokens,
|
||||
};
|
||||
if (!result.calls.length) return usage;
|
||||
if (!tools || round === 6) {
|
||||
throw new TeacherError(502, 'teacher_tools_unavailable', '老师未能完成本次读取,请缩小问题范围后重试。');
|
||||
}
|
||||
const batch: TeacherModelMessage[] = [{ role: 'assistant', content: result.text, tool_calls: result.calls,
|
||||
...(result.reasoning ? { reasoning_content: result.reasoning } : {}) }];
|
||||
const resultBudget = Math.min(2400, Math.floor(((options?.inputLimit ?? Infinity)
|
||||
- toolBudget - estimateTeacherTokens([...messages, ...batch]) - 64 * result.calls.length) / result.calls.length));
|
||||
if (resultBudget < 128) {
|
||||
throw new TeacherError(422, 'teacher_context_too_long', '老师读取的内容超过上下文预算,请缩小问题范围或联系运营增加预算。');
|
||||
}
|
||||
for (const call of result.calls) {
|
||||
signal.throwIfAborted();
|
||||
options?.assertCurrent();
|
||||
batch.push({ role: 'tool', tool_call_id: call.id,
|
||||
content: await tools.execute(call.function.name, call.function.arguments, signal, resultBudget) });
|
||||
}
|
||||
reads.push(batch);
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
async function streamTeacherTurn(
|
||||
config: Pick<TeacherModelConfig, 'api_key' | 'base_url'>,
|
||||
modelId: string,
|
||||
reasoningFields: Record<string, unknown>,
|
||||
outputLimit: number,
|
||||
messages: TeacherModelMessage[],
|
||||
signal: AbortSignal,
|
||||
onText: (text: string) => void,
|
||||
fetchImpl: (input: string | URL, init?: RequestInit) => Promise<Response>,
|
||||
tools: TeacherReadTools | undefined,
|
||||
finalRound: boolean
|
||||
) {
|
||||
// The gateway base already includes its version prefix, as in the existing AI proxy.
|
||||
const response = await fetchImpl(config.base_url.replace(/\/+$/, '') + '/chat/completions', {
|
||||
method: 'POST',
|
||||
@@ -96,6 +157,7 @@ export async function streamTeacherReply(
|
||||
stream_options: { include_usage: true },
|
||||
max_tokens: outputLimit,
|
||||
...reasoningFields,
|
||||
...(tools ? { tools: tools.definitions, tool_choice: finalRound ? 'none' : 'auto', parallel_tool_calls: false } : {}),
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
@@ -116,6 +178,8 @@ export async function streamTeacherReply(
|
||||
let buffer = '',
|
||||
settled = false,
|
||||
usage: PublicUsage | undefined;
|
||||
let text = '', reasoning = '', finishReason: string | null = null;
|
||||
const calls = new Map<number, TeacherToolCall>();
|
||||
const frame = (data: string) => {
|
||||
if (data === '[DONE]') {
|
||||
settled = true;
|
||||
@@ -124,7 +188,9 @@ export async function streamTeacherReply(
|
||||
const event = JSON.parse(data) as {
|
||||
error?: unknown;
|
||||
choices?: Array<{
|
||||
delta?: { content?: unknown; tool_calls?: unknown };
|
||||
delta?: { content?: unknown; reasoning_content?: unknown; tool_calls?: Array<{
|
||||
index: number; id?: string; function?: { name?: string; arguments?: string };
|
||||
}> };
|
||||
finish_reason?: string | null;
|
||||
}>;
|
||||
usage?: { prompt_tokens: number; completion_tokens: number };
|
||||
@@ -132,14 +198,29 @@ export async function streamTeacherReply(
|
||||
if (event.error)
|
||||
throw new TeacherError(502, 'teacher_model_failed', '老师回复中断,请保留当前内容后重试。');
|
||||
const choice = event.choices?.[0];
|
||||
if (choice?.delta?.tool_calls || choice?.finish_reason === 'tool_calls')
|
||||
if (!tools && (choice?.delta?.tool_calls || choice?.finish_reason === 'tool_calls'))
|
||||
throw new TeacherError(
|
||||
502,
|
||||
'teacher_tools_unavailable',
|
||||
'老师只能提供文字建议,本次回复未完成。'
|
||||
);
|
||||
if (typeof choice?.delta?.content === 'string') onText(choice.delta.content);
|
||||
if (choice?.finish_reason === 'stop' || choice?.finish_reason === 'length') settled = true;
|
||||
if (typeof choice?.delta?.content === 'string') {
|
||||
text += choice.delta.content;
|
||||
onText(choice.delta.content);
|
||||
}
|
||||
// Some native reasoning providers require this on the next tool round. It stays Main-private.
|
||||
if (typeof choice?.delta?.reasoning_content === 'string') reasoning += choice.delta.reasoning_content;
|
||||
for (const delta of choice?.delta?.tool_calls ?? []) {
|
||||
if (!Number.isSafeInteger(delta.index) || delta.index < 0 || delta.index >= 8)
|
||||
throw new TeacherError(502, 'teacher_stream_invalid', '老师读取请求格式无效。');
|
||||
const call = calls.get(delta.index) ?? { id: '', type: 'function', function: { name: '', arguments: '' } };
|
||||
if (delta.id) call.id = delta.id;
|
||||
if (delta.function?.name) call.function.name += delta.function.name;
|
||||
if (delta.function?.arguments) call.function.arguments += delta.function.arguments;
|
||||
calls.set(delta.index, call);
|
||||
}
|
||||
if (choice?.finish_reason) finishReason = choice.finish_reason;
|
||||
if (['stop', 'length', 'tool_calls'].includes(finishReason ?? '')) settled = true;
|
||||
if (
|
||||
event.usage &&
|
||||
Number.isFinite(event.usage.prompt_tokens) &&
|
||||
@@ -172,7 +253,10 @@ export async function streamTeacherReply(
|
||||
if (signal.aborted) throw signal.reason;
|
||||
if (!settled)
|
||||
throw new TeacherError(502, 'teacher_stream_interrupted', '回复中断,以下内容可能不完整。');
|
||||
return usage;
|
||||
if ((calls.size && finishReason !== 'tool_calls') || (finishReason === 'tool_calls' && !calls.size)
|
||||
|| [...calls.values()].some(call => !call.id || !call.function.name))
|
||||
throw new TeacherError(502, 'teacher_stream_interrupted', '老师读取请求未完整收到,请重试。');
|
||||
return { usage, text, reasoning, calls: [...calls.values()] };
|
||||
} finally {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
reader.releaseLock();
|
||||
|
||||
Reference in New Issue
Block a user