fix(teacher): 改善只读工具分页与读取预算
This commit is contained in:
@@ -277,6 +277,32 @@ export class CodingProjectFileService {
|
||||
}));
|
||||
}
|
||||
|
||||
async *textChunks(projectPath: string, requestedPath: string, signal: AbortSignal): AsyncGenerator<string> {
|
||||
const relativePath = normalizeRelativePath(requestedPath);
|
||||
const target = await containedExistingTarget(projectPath, relativePath);
|
||||
if (!(await lstat(target)).isFile()) throw new Error('Project file path is not a file');
|
||||
const handle = await open(target, 'r');
|
||||
const stream = handle.createReadStream({ autoClose: false });
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
let pendingCR = '';
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
signal.throwIfAborted();
|
||||
let text = pendingCR + decoder.decode(chunk as Buffer, { stream: true });
|
||||
if (text.includes('\0')) throw new Error('Project file is not UTF-8 text');
|
||||
// A Windows newline can straddle two filesystem chunks.
|
||||
pendingCR = text.endsWith('\r') ? '\r' : '';
|
||||
if (pendingCR) text = text.slice(0, -1);
|
||||
if (text) yield text.replaceAll('\r\n', '\n');
|
||||
}
|
||||
const final = pendingCR + decoder.decode();
|
||||
if (final) yield final;
|
||||
} finally {
|
||||
stream.destroy();
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async content(projectPath: string, requestedPath: string): Promise<CodingProjectFileContent> {
|
||||
const relativePath = normalizeRelativePath(requestedPath);
|
||||
const result = await readBoundedFile(projectPath, relativePath, MAX_CONTENT_BYTES);
|
||||
|
||||
@@ -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,
|
||||
|
||||
58
electron/coding-teacher/read-page.ts
Normal file
58
electron/coding-teacher/read-page.ts
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user