Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 180 additions & 10 deletions apps/backend/src/routes/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,180 @@ import { callOpenAICompatible } from '../services/llmService.js';
import { runToolAgent } from '../services/agentService.js';
import { getLang, t } from '../i18n/index.js';

function stripCodeFence(text) {
const raw = String(text || '').trim();
if (!raw.startsWith('```')) return raw;
return raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
}

function tryParseReplyPayload(text) {
const raw = stripCodeFence(text);
if (!raw) return null;
const candidates = [raw];
const first = raw.indexOf('{');
const last = raw.lastIndexOf('}');
if (first >= 0 && last > first) {
candidates.push(raw.slice(first, last + 1));
}
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate);
if (parsed && typeof parsed === 'object') return parsed;
} catch {
// ignore
}
}
return null;
}

function decodeEscapedChar(ch) {
switch (ch) {
case 'n': return '\n';
case 'r': return '\r';
case 't': return '\t';
case 'b': return '\b';
case 'f': return '\f';
case '"': return '"';
case '\'': return '\'';
case '\\': return '\\';
case '/': return '/';
default: return ch;
}
}

function parseQuotedString(text, quote, startIndex) {
let i = startIndex;
let out = '';
while (i < text.length) {
const ch = text[i];
if (ch === '\\') {
const next = text[i + 1];
if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(text.slice(i + 2, i + 6))) {
out += String.fromCharCode(Number.parseInt(text.slice(i + 2, i + 6), 16));
i += 6;
continue;
}
if (next) {
out += decodeEscapedChar(next);
i += 2;
continue;
}
i += 1;
continue;
}
if (ch === quote) {
return { value: out, end: i };
}
out += ch;
i += 1;
}
return { value: out, end: text.length - 1 };
}

function extractFieldFromJsonLike(text, field) {
const raw = stripCodeFence(text);
if (!raw) return '';
const matcher = new RegExp(`["']?${field}["']?\\s*:\\s*`, 'i');
const match = matcher.exec(raw);
if (!match) return '';
let i = match.index + match[0].length;
while (i < raw.length && /\s/.test(raw[i])) i += 1;
if (i >= raw.length) return '';
const lead = raw[i];
if (lead === '"' || lead === '\'') {
const parsed = parseQuotedString(raw, lead, i + 1);
return parsed.value.trim();
}
let j = i;
while (j < raw.length && ![',', '\n', '\r', '}'].includes(raw[j])) j += 1;
return raw.slice(i, j).trim();
}

function extractMessageContent(value) {
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
return value
.map((item) => {
if (typeof item === 'string') return item;
if (item && typeof item === 'object' && typeof item.text === 'string') return item.text;
return '';
})
.join('')
.trim();
}
return '';
}

function extractCompletionText(parsed) {
if (!parsed || typeof parsed !== 'object') return '';

const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
const choiceMessage = choices[0]?.message;
const choiceContent = extractMessageContent(choiceMessage?.content);
if (choiceContent) return choiceContent;
if (typeof choices[0]?.text === 'string') return choices[0].text;

if (typeof parsed.output_text === 'string') return parsed.output_text;

const candidateParts = parsed?.candidates?.[0]?.content?.parts;
if (Array.isArray(candidateParts)) {
const geminiText = candidateParts.map((part) => String(part?.text || '')).join('').trim();
if (geminiText) return geminiText;
}

return '';
}

function normalizeAgentReply(inputReply, inputSuggestion = '') {
let replyCandidate =
typeof inputReply === 'string'
? inputReply
: JSON.stringify(inputReply || '');
let suggestionCandidate =
typeof inputSuggestion === 'string'
? inputSuggestion
: JSON.stringify(inputSuggestion || '');

for (let depth = 0; depth < 4; depth += 1) {
const parsed = tryParseReplyPayload(replyCandidate);
if (!parsed) {
const fallbackReply = extractFieldFromJsonLike(replyCandidate, 'reply')
|| extractFieldFromJsonLike(replyCandidate, 'message');
const fallbackSuggestion = extractFieldFromJsonLike(replyCandidate, 'suggestion');
if (fallbackReply) replyCandidate = fallbackReply;
if (fallbackSuggestion) suggestionCandidate = fallbackSuggestion;
break;
}

const completionText = extractCompletionText(parsed);
if (completionText && completionText !== replyCandidate) {
replyCandidate = completionText;
continue;
}

const nestedReply = typeof parsed.reply === 'string'
? parsed.reply
: (typeof parsed.message === 'string' ? parsed.message : '');
const nestedSuggestion = typeof parsed.suggestion === 'string' ? parsed.suggestion : '';
if (nestedReply) replyCandidate = nestedReply;
if (nestedSuggestion) suggestionCandidate = nestedSuggestion;
if (!nestedReply) break;
}

if (!suggestionCandidate) {
suggestionCandidate = extractFieldFromJsonLike(replyCandidate, 'suggestion') || '';
}

if (!replyCandidate) {
return {
reply: String(inputReply || ''),
suggestion: String(inputSuggestion || '')
};
}

return { reply: replyCandidate, suggestion: suggestionCandidate };
}

export function registerAgentRoutes(fastify) {
fastify.post('/api/agent/run', async (req) => {
const lang = getLang(req);
Expand Down Expand Up @@ -54,7 +228,9 @@ export function registerAgentRoutes(fastify) {
}

if (mode === 'tools') {
return runToolAgent({ projectId, activePath, task, prompt, selection, compileLog, llmConfig, lang });
const toolResult = await runToolAgent({ projectId, activePath, task, prompt, selection, compileLog, llmConfig, lang });
const normalized = normalizeAgentReply(toolResult?.reply || '', toolResult?.suggestion || '');
return { ...toolResult, reply: normalized.reply, suggestion: normalized.suggestion };
}

const system =
Expand Down Expand Up @@ -97,15 +273,9 @@ export function registerAgentRoutes(fastify) {
};
}

let reply = '';
let suggestion = '';
try {
const parsed = JSON.parse(result.content);
reply = parsed.reply || '';
suggestion = parsed.suggestion || '';
} catch {
reply = result.content;
}
const normalized = normalizeAgentReply(result.content, '');
const reply = normalized.reply;
const suggestion = normalized.suggestion;

return { ok: true, reply, suggestion };
});
Expand Down
48 changes: 41 additions & 7 deletions apps/backend/src/services/agentService.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,36 @@ export async function runToolAgent({

const projectRoot = await getProjectRoot(projectId);
const pendingPatches = [];
const READ_FILE_MAX_CHARS = Number(process.env.OPENPRISM_TOOL_READ_MAX_CHARS || 8000);
const LIST_FILES_MAX_COUNT = Number(process.env.OPENPRISM_TOOL_LIST_MAX_COUNT || 400);

const readFileTool = new DynamicStructuredTool({
name: 'read_file',
description: 'Read a UTF-8 file from the project. Input: { path } (relative to project root).',
schema: z.object({ path: z.string() }),
func: async ({ path: filePath }) => {
description: 'Read a UTF-8 file from the project. Input: { path, startLine?, endLine? } (relative to project root).',
schema: z.object({
path: z.string(),
startLine: z.number().int().min(1).optional(),
endLine: z.number().int().min(1).optional()
}),
func: async ({ path: filePath, startLine, endLine }) => {
const abs = safeJoin(projectRoot, filePath);
const content = await fs.readFile(abs, 'utf8');
return content.slice(0, 20000);
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const lines = normalized.split('\n');
const totalLines = lines.length;
const start = Math.max(1, Math.min(startLine || 1, totalLines));
const end = Math.max(start, Math.min(endLine || totalLines, totalLines));
const selected = lines.slice(start - 1, end).join('\n');
if (selected.length <= READ_FILE_MAX_CHARS) {
return `[read_file] ${filePath} lines ${start}-${end} of ${totalLines}\n${selected}`;
}
const truncated = selected.slice(0, READ_FILE_MAX_CHARS);
return [
`[read_file] ${filePath} lines ${start}-${end} of ${totalLines} (truncated to ${READ_FILE_MAX_CHARS} chars)`,
truncated,
`\n...[truncated ${selected.length - READ_FILE_MAX_CHARS} chars]`,
'If needed, call read_file again with a narrower line range.'
].join('\n');
}
});

Expand All @@ -49,8 +70,16 @@ export async function runToolAgent({
func: async ({ dir }) => {
const root = dir ? safeJoin(projectRoot, dir) : projectRoot;
const items = await listFilesRecursive(root, '');
const files = items.filter((item) => item.type === 'file').map((item) => item.path);
return JSON.stringify({ files });
const files = items
.filter((item) => item.type === 'file')
.map((item) => item.path)
.sort();
const limited = files.slice(0, LIST_FILES_MAX_COUNT);
return JSON.stringify({
files: limited,
total: files.length,
truncated: files.length > limited.length
});
}
});

Expand Down Expand Up @@ -186,7 +215,12 @@ export async function runToolAgent({

const tools = [readFileTool, listFilesTool, proposePatchTool, applyPatchTool, compileLogTool, arxivSearchTool, arxivBibtexTool];
const agent = await createOpenAIToolsAgent({ llm, tools, prompt: promptTemplate });
const executor = new AgentExecutor({ agent, tools });
const executor = new AgentExecutor({
agent,
tools,
maxIterations: Number(process.env.OPENPRISM_TOOL_AGENT_MAX_ITERATIONS || 6),
earlyStoppingMethod: 'force'
});
const result = await executor.invoke({ input: userInput });

return {
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/services/compileService.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ export async function runCompile({ projectId, mainFile, engine = 'pdflatex' }) {
pdfBase64 = '';
}
const log = logChunks.join('');
console.log('--- COMPILATION LOG START ---');
console.log(log);
console.log('--- COMPILATION LOG END ---');
await fs.rm(outDir, { recursive: true, force: true });
if (!pdfBase64) {
return { ok: false, error: 'No PDF generated.', log, status: code ?? -1 };
Expand Down
Loading