fix(teacher): 改善只读工具分页与读取预算

This commit is contained in:
2026-09-27 10:15:17 +08:00
parent 17c7e5c495
commit 56cefe84e7
8 changed files with 297 additions and 56 deletions

View File

@@ -10,7 +10,8 @@ import {
type TeacherAccount,
} from './config-client';
import type { TeacherModelMessage } from './context';
import { createTeacherReadTools, type TeacherReadAccess } from './read-tools';
import { createTeacherReadTools, type TeacherReadAccess, TEACHER_READ_PROTOCOL, TEACHER_READ_ROUNDS,
TEACHER_READ_BYTES, TEACHER_READ_PAGE_BYTES, TEACHER_READ_MIN_BYTES } from './read-tools';
interface TeacherSession {
access_token: string;
@@ -209,6 +210,7 @@ export function prepareCloudTeacher(
|| currentRequest?.presentation === 'discussion-v1';
const localContext = {
id: requestId,
read_protocol: TEACHER_READ_PROTOCOL,
scope: {
project_id: topic.projectId,
source_session_id: currentRequest?.sourceConversationId ?? topic.sourceConversationId,
@@ -274,7 +276,8 @@ export function prepareCloudTeacher(
}
let runId = identifier(queued.run_id),
cursor = '0-0',
rounds = 0;
rounds = 0,
readBytes = 0;
let runText = '',
messageId = '';
while (Date.now() < deadline) {
@@ -308,7 +311,7 @@ export function prepareCloudTeacher(
);
if (!Array.isArray(pending.calls) || !pending.calls.length || pending.calls.length > 32)
throw new TeacherError(502, 'teacher_protocol_invalid', '智能体读取请求格式无效,请重试。');
if (++rounds > 6)
if (++rounds > TEACHER_READ_ROUNDS)
throw new TeacherError(
409,
'teacher_read_limit',
@@ -321,7 +324,11 @@ export function prepareCloudTeacher(
if (typeof call.tool_call_id !== 'string' || typeof call.name !== 'string')
throw new TeacherError(502, 'teacher_protocol_invalid', '智能体读取请求无效。');
reportActivity({ id: requestId + ':' + call.tool_call_id, name: call.name, status: 'running' });
const result = await tools.executeResult(call.name, JSON.stringify(call.arguments), bounded);
const remaining = TEACHER_READ_BYTES - readBytes;
const result = remaining < TEACHER_READ_MIN_BYTES
? { status: 'error' as const, content: 'Read budget exhausted. Answer from the evidence already returned and state any gaps.', truncated: false }
: await tools.executeResult(call.name, JSON.stringify(call.arguments), bounded, Math.min(TEACHER_READ_PAGE_BYTES, remaining));
if (result.status === 'success') readBytes += Buffer.byteLength(result.content);
results.push({
tool_call_id: call.tool_call_id,
...result,

View File

@@ -0,0 +1,58 @@
export function readInteger(value: unknown, fallback: number, maximum = Number.MAX_SAFE_INTEGER): number {
const result = value ?? fallback;
if (typeof result !== 'number' || !Number.isSafeInteger(result) || result < 1 || result > maximum)
throw new Error('Invalid read range');
return result;
}
export function textChunks(text: string): string[] {
return [text.replaceAll('\r\n', '\n')];
}
// JSON contains contiguous original text; columns count Unicode code points, starting at 1.
export async function readPage(
source: string,
chunks: AsyncIterable<string> | Iterable<string>,
args: Record<string, unknown>,
maxBytes: number,
): Promise<{ content: string; truncated: boolean }> {
const start = readInteger(args.start_line, 1);
const column = readInteger(args.start_column, 1);
const count = readInteger(args.line_count, 120, 400);
const chars: string[] = [];
let lineNumber = 1, position = 1, more = false;
outer: for await (const chunk of chunks) {
for (const char of chunk) {
if (lineNumber === start && char === '\n' && position < column)
throw new Error('Column is past the line');
if (lineNumber > start || (lineNumber === start && position >= column)) {
if (lineNumber >= start + count || chars.length >= maxBytes) { more = true; break outer; }
chars.push(char);
}
if (char === '\n') { lineNumber++; position = 1; } else position++;
}
}
if (lineNumber < start || (lineNumber === start && position < column))
throw new Error('Read position is past the source');
const render = (length: number) => {
let nextLine = start, nextColumn = column;
for (let i = 0; i < length; i++) {
if (chars[i] === '\n') { nextLine++; nextColumn = 1; } else nextColumn++;
}
const next = more || length < chars.length ? { start_line: nextLine, start_column: nextColumn } : null;
return { content: JSON.stringify({ source, start_line: start, start_column: column,
text: chars.slice(0, length).join(''), next }), truncated: next !== null };
};
const complete = render(chars.length);
if (Buffer.byteLength(complete.content) <= maxBytes) return complete;
let low = 0, high = chars.length;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (Buffer.byteLength(render(middle).content) <= maxBytes) low = middle;
else high = middle - 1;
}
const page = render(low);
if ((chars.length && !low) || Buffer.byteLength(page.content) > maxBytes)
throw new Error('Read budget too small for a page');
return page;
}

View File

@@ -2,6 +2,13 @@ import path from 'node:path';
import type { TeacherRequest, TeacherSourceContext } from '../../shared/coding-teacher';
import { CodingProjectFileService } from '../coding-projects/project-files';
import { excerptTeacherText, teacherHistoryMessages } from './context';
import { readInteger, readPage, textChunks } from './read-page';
export const TEACHER_READ_PROTOCOL = 2;
export const TEACHER_READ_ROUNDS = 12;
export const TEACHER_READ_BYTES = 64 * 1024;
export const TEACHER_READ_PAGE_BYTES = 8 * 1024;
export const TEACHER_READ_MIN_BYTES = 512;
export interface TeacherReadAccess {
projectPath: string;
@@ -12,19 +19,20 @@ export interface TeacherReadAccess {
const lineParameters = {
start_line: { type: 'integer', minimum: 1, description: 'First line, default 1.' },
line_count: { type: 'integer', minimum: 1, maximum: 100, description: 'Number of lines, default 60.' },
start_column: { type: 'integer', minimum: 1, description: 'Unicode character column, default 1. Copy next from the previous page to continue.' },
line_count: { type: 'integer', minimum: 1, maximum: 400, description: 'Maximum lines, default 120. Output is bounded; use next for the exact continuation.' },
};
export const teacherReadToolDefinitions = [
{ type: 'function', function: {
name: 'list_project_files', description: 'List files and directories in the current project. Start with path ".".',
parameters: { type: 'object', properties: { path: { type: 'string' }, ...lineParameters }, required: ['path'], additionalProperties: false },
name: 'list_project_files', description: 'List a bounded project tree. Start with path "."; use relevant subdirectories for deeper exploration. Files unchanged during a page sequence have stable line order.',
parameters: { type: 'object', properties: { path: { type: 'string' }, depth: { type: 'integer', minimum: 1, maximum: 6, description: 'Tree depth, default 4. Directories at the depth boundary can be listed separately.' }, ...lineParameters }, required: ['path'], additionalProperties: false },
} },
{ type: 'function', function: {
name: 'read_project_file', description: 'Read UTF-8 source from a current-project relative path, including .makelore/project.json metadata. Read only; no commands or edits.',
parameters: { type: 'object', properties: { path: { type: 'string' }, ...lineParameters }, required: ['path'], additionalProperties: false },
} },
{ type: 'function', function: {
name: 'read_conversation', description: 'Read the captured active coding conversation and current teacher topic. Omit message_id to list messages; supply it to read numbered lines.',
name: 'read_conversation', description: 'Read the captured active coding conversation and current teacher topic. Omit message_id to list messages; supply it to read continuous original text. Results give start_line/start_column and next; do not reread unchanged ranges.',
parameters: { type: 'object', properties: { message_id: { type: 'string' }, ...lineParameters }, additionalProperties: false },
} },
];
@@ -39,26 +47,15 @@ function projectPath(value: unknown): string {
return normalized;
}
function lines(text: string, args: Record<string, unknown>): string {
const start = args.start_line ?? 1, count = args.line_count ?? 60;
if (typeof start !== 'number' || !Number.isSafeInteger(start) || start < 1
|| typeof count !== 'number' || !Number.isSafeInteger(count) || count < 1 || count > 100)
throw new Error('Use start_line >= 1 and line_count from 1 to 100.');
const all = text.split(/\r?\n/);
const selected = all.slice(start - 1, start - 1 + count);
return `Lines ${start}-${start + selected.length - 1} of ${all.length}:\n`
+ selected.map((line, index) => `${start + index}: ${line}`).join('\n');
}
export function createTeacherReadTools(access: TeacherReadAccess) {
const files = new CodingProjectFileService();
const messages = [...access.source.messages, ...teacherHistoryMessages(access.history ?? [])];
return {
definitions: teacherReadToolDefinitions,
async execute(name: string, rawArguments: string, signal: AbortSignal, maxBytes = 2400): Promise<string> {
async execute(name: string, rawArguments: string, signal: AbortSignal, maxBytes = TEACHER_READ_PAGE_BYTES): Promise<string> {
return (await this.executeResult(name, rawArguments, signal, maxBytes)).content;
},
async executeResult(name: string, rawArguments: string, signal: AbortSignal, maxBytes = 2400): Promise<{ status: 'success' | 'error'; content: string; truncated: boolean }> {
async executeResult(name: string, rawArguments: string, signal: AbortSignal, maxBytes = TEACHER_READ_PAGE_BYTES): Promise<{ status: 'success' | 'error'; content: string; truncated: boolean }> {
signal.throwIfAborted();
access.assertCurrent();
let result: string;
@@ -69,27 +66,43 @@ export function createTeacherReadTools(access: TeacherReadAccess) {
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Expected an object.');
switch (name) {
case 'list_project_files': {
const entries = (await files.directory(access.projectPath, projectPath(args.path)))
.filter(entry => !['.makelore', '.git'].includes(entry.name.toLowerCase()));
result = lines(entries.map(entry => entry.path + (entry.type === 'directory' ? '/' : '')).join('\n') || '(empty directory)', args);
const root = projectPath(args.path);
const depth = readInteger(args.depth, 4, 6);
async function* tree(): AsyncGenerator<string> {
const directories = [{ path: root, level: 1 }];
let found = false;
for (let i = 0; i < directories.length; i++) {
signal.throwIfAborted();
access.assertCurrent();
const directory = directories[i];
for (const entry of await files.directory(access.projectPath, directory.path)) {
if (['.makelore', '.git', '.gradle'].includes(entry.name.toLowerCase())) continue;
found = true;
yield entry.path + (entry.type === 'directory' ? '/' : '') + '\n';
if (entry.type === 'directory' && directory.level < depth)
directories.push({ path: entry.path, level: directory.level + 1 });
}
}
if (!found) yield '(empty directory)';
}
({ content: result, truncated } = await readPage(root + ' (tree depth ' + depth + ')',
tree(), args, maxBytes));
break;
}
case 'read_project_file': {
const file = await files.content(access.projectPath, projectPath(args.path));
truncated = file.truncated;
result = file.path + '\n' + lines(file.content, args)
+ (file.truncated ? '\n[File exceeds the 256 KiB text preview limit; only its beginning is available.]' : '');
const target = projectPath(args.path);
({ content: result, truncated } = await readPage(target,
files.textChunks(access.projectPath, target, signal), args, maxBytes));
break;
}
case 'read_conversation': {
if (args.message_id === undefined) {
result = lines(messages.map(message => `${message.id} ${message.role}: ${excerptTeacherText(message.text, 180).replaceAll('\n', ' ')}`).join('\n')
|| '(no completed text messages in this conversation)', args);
} else {
const message = messages.find(message => message.id === args.message_id);
if (!message) throw new Error('Message is not in the current conversation.');
result = message.id + ' ' + message.role + '\n' + lines(message.text, args);
}
const message = args.message_id === undefined ? undefined : messages.find(item => item.id === args.message_id);
if (args.message_id !== undefined && !message) throw new Error('Message is not in the current conversation.');
const text = message?.text ?? (messages.map(item =>
`${item.id} ${item.role}: ${excerptTeacherText(item.text, 180).replaceAll('\n', ' ')}`).join('\n')
|| '(no completed text messages in this conversation)');
({ content: result, truncated } = await readPage(message ? message.id + ' ' + message.role : 'conversation message index (snippets)',
textChunks(text), args, maxBytes));
break;
}
default:
@@ -103,7 +116,8 @@ export function createTeacherReadTools(access: TeacherReadAccess) {
}
signal.throwIfAborted();
access.assertCurrent();
const content = excerptTeacherText(result, maxBytes);
// Only the generic error can need truncation; source pages already fit exactly.
const content = status === 'error' ? excerptTeacherText(result, maxBytes) : result;
return { status, content, truncated: truncated || content !== result };
},
};