4208 lines
139 KiB
TypeScript
4208 lines
139 KiB
TypeScript
// @preview-file off clear
|
|
import { App, Path, Content } from 'Dora';
|
|
import { Flow, Node } from 'Agent/flow';
|
|
import { callLLM, callLLMStreamAggregated, Message, StopToken, Log, getActiveLLMConfig, createLocalToolCallId, parseSimpleXMLChildren, parseXMLObjectFromText, safeJsonDecode, safeJsonEncode, sanitizeUTF8, estimateTextTokens } from 'Agent/Utils';
|
|
import type { LLMConfig, ToolCall } from 'Agent/Utils';
|
|
import * as Tools from 'Agent/Tools';
|
|
import { MemoryCompressor } from 'Agent/Memory';
|
|
import type { AgentPromptPack, AgentConversationMessage } from 'Agent/Memory';
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object";
|
|
}
|
|
|
|
function isArray(value: unknown): value is any[] {
|
|
return Array.isArray(value);
|
|
}
|
|
|
|
interface SkillMetadata {
|
|
name: string;
|
|
description: string;
|
|
always?: boolean;
|
|
}
|
|
|
|
interface Skill extends SkillMetadata {
|
|
location: string;
|
|
body?: string;
|
|
}
|
|
|
|
interface SkillsLoaderConfig {
|
|
projectDir: string;
|
|
}
|
|
|
|
enum SkillPriority {
|
|
BuiltIn = 0,
|
|
User = 1,
|
|
Project = 2,
|
|
}
|
|
|
|
interface SkillEntry {
|
|
skill: Skill;
|
|
priority: SkillPriority;
|
|
}
|
|
|
|
function stripWrappingQuotes(value: string): string {
|
|
let [result] = string.gsub(value, '^"(.*)"$', "%1");
|
|
[result] = string.gsub(result, "^'(.*)'$", "%1");
|
|
return result;
|
|
}
|
|
|
|
function escapeXMLText(text: string): string {
|
|
let [result] = string.gsub(text, "&", "&");
|
|
[result] = string.gsub(result, "<", "<");
|
|
[result] = string.gsub(result, ">", ">");
|
|
[result] = string.gsub(result, '"', """);
|
|
[result] = string.gsub(result, "'", "'");
|
|
return result;
|
|
}
|
|
|
|
function parseYAMLFrontmatter(content: string): {
|
|
metadata: Record<string, unknown> | undefined;
|
|
body: string;
|
|
error?: string;
|
|
} {
|
|
if (!content || content.trim() === "") {
|
|
return { metadata: undefined, body: "", error: "empty content" };
|
|
}
|
|
|
|
const trimmed = content.trim();
|
|
if (!trimmed.startsWith("---")) {
|
|
return { metadata: undefined, body: content };
|
|
}
|
|
|
|
const lines = trimmed.split("\n");
|
|
let endLine = -1;
|
|
for (let i = 1; i < lines.length; i++) {
|
|
if (lines[i].trim() === "---") {
|
|
endLine = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (endLine < 0) {
|
|
return { metadata: undefined, body: content, error: "missing closing ---" };
|
|
}
|
|
|
|
const frontmatterLines = lines.slice(1, endLine);
|
|
const frontmatterText = frontmatterLines.join("\n").trim();
|
|
|
|
const metadata = parseSimpleYAML(frontmatterText);
|
|
|
|
const bodyLines = lines.slice(endLine + 1);
|
|
const body = bodyLines.join("\n").trim();
|
|
|
|
return { metadata, body };
|
|
}
|
|
|
|
function parseSimpleYAML(text: string): Record<string, unknown> | undefined {
|
|
if (!text || text.trim() === "") {
|
|
return undefined;
|
|
}
|
|
|
|
const result: Record<string, unknown> = {};
|
|
const lines = text.split("\n");
|
|
let currentKey = "";
|
|
let currentArray: string[] | undefined = undefined;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmed = line.trim();
|
|
|
|
if (trimmed === "" || trimmed.startsWith("#")) {
|
|
continue;
|
|
}
|
|
|
|
if (trimmed.startsWith("- ")) {
|
|
if (currentArray !== undefined && currentKey !== "") {
|
|
const value = trimmed.substring(2).trim();
|
|
const cleaned = stripWrappingQuotes(value);
|
|
currentArray.push(cleaned);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const colonIndex = trimmed.indexOf(":");
|
|
if (colonIndex > 0) {
|
|
if (currentArray !== undefined && currentKey !== "") {
|
|
result[currentKey] = currentArray;
|
|
currentArray = undefined;
|
|
}
|
|
|
|
const key = trimmed.substring(0, colonIndex).trim();
|
|
let value = trimmed.substring(colonIndex + 1).trim();
|
|
|
|
if (value.startsWith("[") && value.endsWith("]")) {
|
|
const arrayText = value.substring(1, value.length - 1);
|
|
const items = arrayText.split(",").map(item => {
|
|
const cleaned = stripWrappingQuotes(item.trim());
|
|
return cleaned;
|
|
});
|
|
result[key] = items;
|
|
continue;
|
|
}
|
|
|
|
if (value === "true") {
|
|
result[key] = true;
|
|
continue;
|
|
}
|
|
if (value === "false") {
|
|
result[key] = false;
|
|
continue;
|
|
}
|
|
|
|
if (value === "") {
|
|
currentKey = key;
|
|
currentArray = [];
|
|
if (i + 1 < lines.length) {
|
|
const nextLine = lines[i + 1].trim();
|
|
if (!nextLine.startsWith("- ")) {
|
|
currentArray = undefined;
|
|
result[key] = "";
|
|
}
|
|
} else {
|
|
currentArray = undefined;
|
|
result[key] = "";
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const cleaned = stripWrappingQuotes(value);
|
|
result[key] = cleaned;
|
|
currentKey = "";
|
|
currentArray = undefined;
|
|
}
|
|
}
|
|
|
|
if (currentArray !== undefined && currentKey !== "") {
|
|
result[currentKey] = currentArray;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function validateSkillMetadata(
|
|
metadata?: Record<string, unknown>
|
|
): { metadata: SkillMetadata; error?: string } {
|
|
if (!metadata) {
|
|
return {
|
|
metadata: {
|
|
name: "",
|
|
description: "",
|
|
},
|
|
error: "missing frontmatter",
|
|
};
|
|
}
|
|
|
|
const name = typeof metadata.name === "string" ? metadata.name.trim() : "";
|
|
if (name === "") {
|
|
return {
|
|
metadata: {
|
|
name: "",
|
|
description: "",
|
|
},
|
|
error: "missing name in frontmatter",
|
|
};
|
|
}
|
|
|
|
const description = typeof metadata.description === "string"
|
|
? metadata.description.trim()
|
|
: "";
|
|
|
|
const always = metadata.always === true;
|
|
|
|
return {
|
|
metadata: {
|
|
name,
|
|
description,
|
|
always
|
|
},
|
|
};
|
|
}
|
|
|
|
class SkillsLoader {
|
|
private config: SkillsLoaderConfig;
|
|
private skills: Map<string, SkillEntry> = new Map();
|
|
private loaded = false;
|
|
|
|
constructor(config: SkillsLoaderConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
load(): void {
|
|
this.skills.clear();
|
|
|
|
const builtInDir = Path(Content.assetPath, "Doc", "skills");
|
|
const builtInParent = Content.assetPath;
|
|
this.loadSkillsFromDir(builtInDir, builtInParent, SkillPriority.BuiltIn);
|
|
|
|
const userDir = Path(Content.writablePath, ".agent", "skills");
|
|
const userParent = Content.writablePath;
|
|
this.loadSkillsFromDir(userDir, userParent, SkillPriority.User);
|
|
|
|
const projectDir = Path(this.config.projectDir, ".agent", "skills");
|
|
const projectParent = this.config.projectDir;
|
|
this.loadSkillsFromDir(projectDir, projectParent, SkillPriority.Project);
|
|
|
|
this.loaded = true;
|
|
Log("Info", `[SkillsLoader] Loaded ${this.skills.size} skills`);
|
|
}
|
|
|
|
private loadSkillsFromDir(dir: string, parent: string, priority: SkillPriority): void {
|
|
if (!Content.exist(dir) || !Content.isdir(dir)) {
|
|
return;
|
|
}
|
|
|
|
const subdirs = Content.getDirs(dir);
|
|
if (!subdirs || subdirs.length === 0) {
|
|
return;
|
|
}
|
|
|
|
for (const subdir of subdirs) {
|
|
const skillPath = Path(dir, subdir, "SKILL.md");
|
|
if (!Content.exist(skillPath)) {
|
|
continue;
|
|
}
|
|
|
|
const skill = this.loadSkillFile(skillPath);
|
|
if (!skill) {
|
|
continue;
|
|
}
|
|
|
|
skill.location = Path.getRelative(skillPath, parent);
|
|
|
|
const existing = this.skills.get(skill.name);
|
|
if (existing && existing.priority >= priority) {
|
|
continue;
|
|
}
|
|
|
|
this.skills.set(skill.name, { skill, priority });
|
|
}
|
|
}
|
|
|
|
private loadSkillFile(skillPath: string): Skill | undefined {
|
|
const content = Content.load(skillPath);
|
|
if (!content) {
|
|
Log("Warn", `[SkillsLoader] Failed to read ${skillPath}`);
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = parseYAMLFrontmatter(content);
|
|
const validated = validateSkillMetadata(parsed.metadata);
|
|
|
|
if (validated.error) {
|
|
Log("Warn", `[SkillsLoader] Invalid SKILL.md at ${skillPath}: ${validated.error}`);
|
|
return undefined;
|
|
}
|
|
|
|
let displayLocation = skillPath;
|
|
if (skillPath.startsWith(this.config.projectDir)) {
|
|
displayLocation = Path.getRelative(skillPath, this.config.projectDir);
|
|
}
|
|
|
|
const skill: Skill = {
|
|
...validated.metadata,
|
|
location: displayLocation,
|
|
body: parsed.body,
|
|
};
|
|
|
|
return skill;
|
|
}
|
|
|
|
getAllSkills(): Skill[] {
|
|
if (!this.loaded) {
|
|
this.load();
|
|
}
|
|
|
|
const result: Skill[] = [];
|
|
for (const entry of this.skills.values()) {
|
|
result.push(entry.skill);
|
|
}
|
|
|
|
result.sort((a, b) => {
|
|
if (a.name < b.name) {
|
|
return -1;
|
|
}
|
|
if (a.name > b.name) {
|
|
return 1;
|
|
}
|
|
if (a.location < b.location) {
|
|
return -1;
|
|
}
|
|
if (a.location > b.location) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
getSkill(name: string): Skill | undefined {
|
|
if (!this.loaded) {
|
|
this.load();
|
|
}
|
|
|
|
return this.skills.get(name)?.skill;
|
|
}
|
|
|
|
getAlwaysSkills(): Skill[] {
|
|
const all = this.getAllSkills();
|
|
return all.filter(skill => skill.always === true);
|
|
}
|
|
|
|
getSummarySkills(): Skill[] {
|
|
const all = this.getAllSkills();
|
|
return all.filter(skill => skill.always !== true);
|
|
}
|
|
|
|
buildLevel1Summary(): string {
|
|
const skills = this.getSummarySkills();
|
|
|
|
if (skills.length === 0) {
|
|
return "";
|
|
}
|
|
|
|
const parts: string[] = [];
|
|
|
|
for (const skill of skills) {
|
|
let skillXML = `<skill>\n`;
|
|
skillXML += ` <name>${this.escapeXML(skill.name)}</name>\n`;
|
|
skillXML += ` <description>${this.escapeXML(skill.description)}</description>\n`;
|
|
skillXML += ` <location>${this.escapeXML(skill.location)}</location>\n`;
|
|
skillXML += `</skill>`;
|
|
parts.push(skillXML);
|
|
}
|
|
|
|
return parts.join("\n\n");
|
|
}
|
|
|
|
buildActiveSkillsContent(): string {
|
|
const skills = this.getAlwaysSkills();
|
|
|
|
if (skills.length === 0) {
|
|
return "";
|
|
}
|
|
|
|
const parts: string[] = [];
|
|
|
|
for (const skill of skills) {
|
|
parts.push(`## Skill: ${skill.name}\n`);
|
|
if (skill.description !== undefined) {
|
|
parts.push(`${skill.description}\n`);
|
|
}
|
|
if (skill.body && skill.body.trim() !== "") {
|
|
parts.push(`\n${skill.body}`);
|
|
}
|
|
parts.push("");
|
|
}
|
|
|
|
return parts.join("\n");
|
|
}
|
|
|
|
loadSkillContent(name: string): string | undefined {
|
|
const skill = this.getSkill(name);
|
|
if (!skill) {
|
|
return undefined;
|
|
}
|
|
|
|
if (skill.body && skill.body.trim() !== "") {
|
|
return skill.body;
|
|
}
|
|
|
|
const content = Content.load(skill.location);
|
|
if (!content) {
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = parseYAMLFrontmatter(content);
|
|
return parsed.body || undefined;
|
|
}
|
|
|
|
buildSkillsPromptSection(): string {
|
|
if (!this.loaded) {
|
|
this.load();
|
|
}
|
|
|
|
const sections: string[] = [];
|
|
|
|
const activeContent = this.buildActiveSkillsContent();
|
|
sections.push(`# Active Skills\n\n${activeContent}`);
|
|
|
|
const summary = this.buildLevel1Summary();
|
|
sections.push(`# Skills\n\nRead a skill's SKILL.md with \`read_file\` for full instructions.\n\n${summary}`);
|
|
|
|
return sections.join("\n\n---\n\n");
|
|
}
|
|
|
|
private escapeXML(text: string): string {
|
|
return escapeXMLText(text);
|
|
}
|
|
|
|
reload(): void {
|
|
this.loaded = false;
|
|
this.load();
|
|
}
|
|
|
|
getSkillCount(): number {
|
|
if (!this.loaded) {
|
|
this.load();
|
|
}
|
|
return this.skills.size;
|
|
}
|
|
}
|
|
|
|
function createSkillsLoader(config: SkillsLoaderConfig): SkillsLoader {
|
|
return new SkillsLoader(config);
|
|
}
|
|
|
|
export type CodingAgentRunResult =
|
|
| {
|
|
success: true;
|
|
taskId: number;
|
|
message: string;
|
|
steps: number;
|
|
}
|
|
| {
|
|
success: false;
|
|
taskId?: number;
|
|
message: string;
|
|
steps?: number;
|
|
};
|
|
|
|
export interface CodingAgentRunOptions {
|
|
prompt: string;
|
|
workDir: string;
|
|
useChineseResponse?: boolean;
|
|
taskId?: number;
|
|
maxSteps?: number;
|
|
decisionMode?: "tool_calling" | "xml";
|
|
llmMaxTry?: number;
|
|
llmOptions?: Record<string, unknown>;
|
|
llmConfig?: LLMConfig;
|
|
promptPack?: Partial<AgentPromptPack>;
|
|
stopToken?: StopToken;
|
|
sessionId?: number;
|
|
memoryScope?: string;
|
|
role?: "main" | "sub";
|
|
spawnSubAgent?: (this: void, request: {
|
|
parentSessionId: number;
|
|
projectRoot?: string;
|
|
title: string;
|
|
prompt: string;
|
|
expectedOutput?: string;
|
|
filesHint?: string[];
|
|
}) => Promise<
|
|
| { success: true; sessionId: number; taskId: number; title: string }
|
|
| { success: false; message: string }
|
|
>;
|
|
listSubAgents?: (this: void, request: {
|
|
sessionId: number;
|
|
projectRoot?: string;
|
|
status?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
query?: string;
|
|
}) => Promise<
|
|
| {
|
|
success: true;
|
|
rootSessionId: number;
|
|
maxConcurrent: number;
|
|
status: string;
|
|
limit: number;
|
|
offset: number;
|
|
hasMore: boolean;
|
|
sessions: {
|
|
sessionId: number;
|
|
title: string;
|
|
parentSessionId?: number;
|
|
rootSessionId: number;
|
|
status: string;
|
|
currentTaskId?: number;
|
|
currentTaskStatus?: string;
|
|
goal?: string;
|
|
expectedOutput?: string;
|
|
filesHint?: string[];
|
|
summary?: string;
|
|
success?: boolean;
|
|
resultFilePath?: string;
|
|
artifactDir?: string;
|
|
finishedAt?: string;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
}[];
|
|
}
|
|
| { success: false; message: string }
|
|
>;
|
|
onEvent?: (event: CodingAgentEvent) => void;
|
|
}
|
|
|
|
export const AGENT_USER_PROMPT_MAX_CHARS = 12000;
|
|
|
|
type AgentDecisionMode = "tool_calling" | "xml";
|
|
type AgentPromptCommand = "compact" | "clear";
|
|
type AgentRole = "main" | "sub";
|
|
|
|
export type AgentToolName =
|
|
| "read_file"
|
|
| "edit_file"
|
|
| "delete_file"
|
|
| "grep_files"
|
|
| "search_dora_api"
|
|
| "glob_files"
|
|
| "build"
|
|
| "list_sub_agents"
|
|
| "spawn_sub_agent"
|
|
| "finish";
|
|
|
|
export type AgentStepToolName = AgentToolName | "compress_memory";
|
|
|
|
export interface AgentContextMetric {
|
|
usedTokens: number;
|
|
maxTokens: number;
|
|
ratio: number;
|
|
messagesTokens: number;
|
|
optionsTokens: number;
|
|
toolDefinitionsTokens?: number;
|
|
reservedOutputTokens: number;
|
|
structuralOverhead: number;
|
|
contextWindow: number;
|
|
source: string;
|
|
updatedAt: number;
|
|
phase?: string;
|
|
step?: number;
|
|
}
|
|
|
|
export interface AgentMetrics {
|
|
context?: AgentContextMetric;
|
|
}
|
|
|
|
export type CodingAgentEvent =
|
|
| {
|
|
type: "task_started";
|
|
sessionId?: number;
|
|
taskId: number;
|
|
prompt: string;
|
|
workDir: string;
|
|
maxSteps: number;
|
|
}
|
|
| {
|
|
type: "decision_made";
|
|
sessionId?: number;
|
|
taskId: number;
|
|
step: number;
|
|
tool: AgentToolName;
|
|
reason?: string;
|
|
reasoningContent?: string;
|
|
params: Record<string, unknown>;
|
|
}
|
|
| {
|
|
type: "tool_started";
|
|
sessionId?: number;
|
|
taskId: number;
|
|
step: number;
|
|
tool: AgentToolName;
|
|
}
|
|
| {
|
|
type: "tool_finished";
|
|
sessionId?: number;
|
|
taskId: number;
|
|
step: number;
|
|
tool: AgentToolName;
|
|
reason?: string;
|
|
result: Record<string, unknown>;
|
|
}
|
|
| {
|
|
type: "checkpoint_created";
|
|
sessionId?: number;
|
|
taskId: number;
|
|
step: number;
|
|
tool: "edit_file" | "delete_file";
|
|
checkpointId: number;
|
|
checkpointSeq: number;
|
|
files: {
|
|
path: string;
|
|
op: "write" | "create" | "delete";
|
|
}[];
|
|
}
|
|
| {
|
|
type: "memory_compression_started";
|
|
sessionId?: number;
|
|
taskId: number;
|
|
step: number;
|
|
tool: "compress_memory";
|
|
reason?: string;
|
|
params: Record<string, unknown>;
|
|
}
|
|
| {
|
|
type: "memory_compression_finished";
|
|
sessionId?: number;
|
|
taskId: number;
|
|
step: number;
|
|
tool: "compress_memory";
|
|
reason?: string;
|
|
result: Record<string, unknown>;
|
|
}
|
|
| {
|
|
type: "metrics_updated";
|
|
sessionId?: number;
|
|
taskId: number;
|
|
step?: number;
|
|
metrics: AgentMetrics;
|
|
}
|
|
| {
|
|
type: "assistant_message_updated";
|
|
sessionId?: number;
|
|
taskId: number;
|
|
step: number;
|
|
content: string;
|
|
reasoningContent?: string;
|
|
}
|
|
| {
|
|
type: "task_finished";
|
|
sessionId?: number;
|
|
taskId?: number;
|
|
success: boolean;
|
|
message: string;
|
|
steps?: number;
|
|
};
|
|
|
|
const HISTORY_READ_FILE_MAX_CHARS = 12000;
|
|
const HISTORY_READ_FILE_MAX_LINES = 300;
|
|
const READ_FILE_DEFAULT_LIMIT = 300;
|
|
const HISTORY_SEARCH_FILES_MAX_MATCHES = 20;
|
|
const HISTORY_SEARCH_DORA_API_MAX_MATCHES = 12;
|
|
const HISTORY_LIST_FILES_MAX_ENTRIES = 200;
|
|
const HISTORY_BUILD_MAX_MESSAGES = 50;
|
|
const HISTORY_BUILD_MESSAGE_MAX_CHARS = 1200;
|
|
const SEARCH_DORA_API_LIMIT_MAX = 20;
|
|
const SEARCH_FILES_LIMIT_DEFAULT = 20;
|
|
const LIST_FILES_MAX_ENTRIES_DEFAULT = 200;
|
|
const SEARCH_PREVIEW_CONTEXT = 80;
|
|
const AGENT_DEFAULT_MAX_STEPS = 100;
|
|
const AGENT_DEFAULT_LLM_MAX_TRY = 5;
|
|
const AGENT_DEFAULT_LLM_TEMPERATURE = 0.1;
|
|
const AGENT_DEFAULT_LLM_MAX_TOKENS = 8192;
|
|
|
|
function buildLLMOptions(llmConfig: LLMConfig, overrides?: Record<string, unknown>): Record<string, unknown> {
|
|
const options: Record<string, unknown> = {
|
|
temperature: llmConfig.temperature ?? AGENT_DEFAULT_LLM_TEMPERATURE,
|
|
max_tokens: llmConfig.maxTokens ?? AGENT_DEFAULT_LLM_MAX_TOKENS,
|
|
};
|
|
if (llmConfig.reasoningEffort) {
|
|
options.reasoning_effort = llmConfig.reasoningEffort;
|
|
}
|
|
const merged = {
|
|
...options,
|
|
...(overrides ?? {}),
|
|
};
|
|
if (typeof merged.reasoning_effort !== "string" || merged.reasoning_effort.trim() === "") {
|
|
delete merged.reasoning_effort;
|
|
} else {
|
|
merged.reasoning_effort = merged.reasoning_effort.trim();
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
export interface AgentActionRecord {
|
|
step: number;
|
|
toolCallId: string;
|
|
tool: AgentToolName;
|
|
reason: string;
|
|
reasoningContent?: string;
|
|
params: Record<string, unknown>;
|
|
result?: Record<string, unknown>;
|
|
timestamp: string;
|
|
}
|
|
|
|
interface PreExecutedToolResult {
|
|
matches(action: AgentActionRecord): boolean;
|
|
promise: Promise<Record<string, unknown>>;
|
|
}
|
|
|
|
interface AgentFileContextItem {
|
|
path: string;
|
|
op: Tools.FileOp;
|
|
checkpointId: number;
|
|
checkpointSeq: number;
|
|
beforeExists: boolean;
|
|
afterExists: boolean;
|
|
beforeBytes: number;
|
|
afterBytes: number;
|
|
diffPreview: string;
|
|
beforeContentPreview?: string;
|
|
afterContent?: string;
|
|
afterContentPreview?: string;
|
|
lineCount?: number;
|
|
contentTruncated: boolean;
|
|
fileListTruncated: boolean;
|
|
}
|
|
|
|
interface AgentShared {
|
|
sessionId?: number;
|
|
taskId: number;
|
|
role: AgentRole;
|
|
maxSteps: number;
|
|
step: number;
|
|
done: boolean;
|
|
stopToken: StopToken;
|
|
error?: string;
|
|
response?: string;
|
|
userQuery: string;
|
|
workingDir: string;
|
|
useChineseResponse: boolean;
|
|
decisionMode: AgentDecisionMode;
|
|
llmOptions: Record<string, unknown>;
|
|
llmConfig: LLMConfig;
|
|
llmMaxTry: number;
|
|
onEvent?: (event: CodingAgentEvent) => void;
|
|
promptPack: AgentPromptPack;
|
|
history: AgentActionRecord[];
|
|
pendingToolActions?: AgentActionRecord[];
|
|
preExecutedResults?: Map<string, PreExecutedToolResult>;
|
|
messages: AgentConversationMessage[];
|
|
lastConsolidatedIndex: number;
|
|
carryMessageIndex?: number;
|
|
// Memory 相关字段
|
|
memory: {
|
|
/** Memory 压缩器实例 */
|
|
compressor: MemoryCompressor;
|
|
};
|
|
// Skills 相关字段
|
|
skills: {
|
|
/** Skills 加载器实例 */
|
|
loader: SkillsLoader;
|
|
};
|
|
spawnSubAgent?: CodingAgentRunOptions["spawnSubAgent"];
|
|
listSubAgents?: CodingAgentRunOptions["listSubAgents"];
|
|
}
|
|
|
|
function emitAgentEvent(shared: AgentShared, event: CodingAgentEvent) {
|
|
if (shared.onEvent) {
|
|
try {
|
|
shared.onEvent(event);
|
|
} catch (error) {
|
|
Log("Error", `[CodingAgent] onEvent handler failed: ${tostring(error)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function emitLLMContextMetrics(
|
|
shared: AgentShared,
|
|
step: number,
|
|
phase: string,
|
|
messages: Message[],
|
|
options: Record<string, unknown>
|
|
) {
|
|
let messagesTokens = 0;
|
|
for (let i = 0; i < messages.length; i++) {
|
|
const message = messages[i];
|
|
messagesTokens += 8;
|
|
messagesTokens += estimateTextTokens(message.role ?? "");
|
|
messagesTokens += estimateTextTokens(message.content ?? "");
|
|
messagesTokens += estimateTextTokens(message.name ?? "");
|
|
messagesTokens += estimateTextTokens(message.tool_call_id ?? "");
|
|
messagesTokens += estimateTextTokens(message.reasoning_content ?? "");
|
|
const [toolCallsText] = safeJsonEncode((message.tool_calls ?? []) as object);
|
|
messagesTokens += estimateTextTokens(toolCallsText ?? "");
|
|
}
|
|
// Calculate tool definitions separately - they are fixed overhead, not conversation usage
|
|
let toolDefinitionsTokens = 0;
|
|
if (options.tools && Array.isArray(options.tools)) {
|
|
const [toolsText] = safeJsonEncode(options.tools as object);
|
|
toolDefinitionsTokens = toolsText ? estimateTextTokens(toolsText) : 0;
|
|
}
|
|
// Exclude tools from optionsTokens since we track them separately
|
|
const optionsWithoutTools = { ...options };
|
|
delete optionsWithoutTools.tools;
|
|
const [optionsText] = safeJsonEncode(optionsWithoutTools as object);
|
|
const optionsTokens = optionsText ? estimateTextTokens(optionsText) : 0;
|
|
const contextWindow = math.max(64000, shared.llmConfig.contextWindow);
|
|
const explicitMax = typeof options.max_tokens === "number"
|
|
? math.floor(options.max_tokens)
|
|
: (typeof options.max_completion_tokens === "number"
|
|
? math.floor(options.max_completion_tokens)
|
|
: 0);
|
|
const reservedOutputTokens = explicitMax > 0
|
|
? math.max(256, explicitMax)
|
|
: math.max(1024, math.floor(contextWindow * 0.2));
|
|
const structuralOverhead = math.max(256, messages.length * 16);
|
|
// usedTokens represents actual conversation content, not fixed overhead like tool definitions
|
|
const usedTokens = messagesTokens + optionsTokens + structuralOverhead;
|
|
const maxTokens = contextWindow;
|
|
emitAgentEvent(shared, {
|
|
type: "metrics_updated",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step,
|
|
metrics: {
|
|
context: {
|
|
usedTokens,
|
|
maxTokens,
|
|
ratio: math.max(0, math.min(1, usedTokens / maxTokens)),
|
|
messagesTokens,
|
|
optionsTokens,
|
|
toolDefinitionsTokens,
|
|
reservedOutputTokens,
|
|
structuralOverhead,
|
|
contextWindow,
|
|
source: "llm_input_estimate",
|
|
updatedAt: os.time(),
|
|
phase,
|
|
step,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function emitAgentStartEvent(shared: AgentShared, action: AgentActionRecord) {
|
|
emitAgentEvent(shared, {
|
|
type: "tool_started",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step: action.step,
|
|
tool: action.tool,
|
|
});
|
|
}
|
|
|
|
function emitAgentFinishEvent(shared: AgentShared, action: AgentActionRecord) {
|
|
emitAgentEvent(shared, {
|
|
type: "tool_finished",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step: action.step,
|
|
tool: action.tool,
|
|
result: action.result ?? {},
|
|
});
|
|
}
|
|
|
|
function emitAssistantMessageUpdated(shared: AgentShared, content: string, reasoningContent?: string) {
|
|
emitAgentEvent(shared, {
|
|
type: "assistant_message_updated",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step: shared.step + 1,
|
|
content,
|
|
reasoningContent,
|
|
});
|
|
}
|
|
|
|
function getMemoryCompressionStartReason(shared: AgentShared): string {
|
|
return shared.useChineseResponse
|
|
? `开始进行上下文记忆压缩。`
|
|
: `Starting context memory compression.`;
|
|
}
|
|
|
|
function getMemoryCompressionSuccessReason(shared: AgentShared, compressedCount: number): string {
|
|
return shared.useChineseResponse
|
|
? `记忆压缩完成,已整理 ${compressedCount} 条历史消息。`
|
|
: `Memory compression finished after consolidating ${compressedCount} historical messages.`;
|
|
}
|
|
|
|
function getMemoryCompressionFailureReason(shared: AgentShared, error: string): string {
|
|
return shared.useChineseResponse
|
|
? `记忆压缩失败:${error}`
|
|
: `Memory compression failed: ${error}`;
|
|
}
|
|
|
|
function summarizeHistoryEntryPreview(text: string, maxChars = 180): string {
|
|
const trimmed = text.trim();
|
|
if (trimmed === "") return "";
|
|
return truncateText(trimmed, maxChars);
|
|
}
|
|
|
|
function getCancelledReason(shared: AgentShared): string {
|
|
if (shared.stopToken.reason && shared.stopToken.reason !== "") return shared.stopToken.reason;
|
|
return shared.useChineseResponse ? "已取消" : "cancelled";
|
|
}
|
|
|
|
function getMaxStepsReachedReason(shared: AgentShared): string {
|
|
return shared.useChineseResponse
|
|
? `已达到最大执行步数限制(${shared.maxSteps} 步)。如需继续后续处理,请发送“继续”。`
|
|
: `Maximum step limit reached (${shared.maxSteps} steps). Send "continue" if you want to proceed with the remaining work.`;
|
|
}
|
|
|
|
function getFailureSummaryFallback(shared: AgentShared, error: string): string {
|
|
return shared.useChineseResponse
|
|
? `任务因以下问题结束:${error}`
|
|
: `The task ended due to the following issue: ${error}`;
|
|
}
|
|
|
|
function finalizeAgentFailure(shared: AgentShared, error: string): CodingAgentRunResult {
|
|
if (shared.stopToken.stopped) {
|
|
Tools.setTaskStatus(shared.taskId, "STOPPED");
|
|
return emitAgentTaskFinishEvent(shared, false, getCancelledReason(shared));
|
|
}
|
|
Tools.setTaskStatus(shared.taskId, "FAILED");
|
|
return emitAgentTaskFinishEvent(shared, false, error);
|
|
}
|
|
|
|
function getPromptCommand(prompt: string): AgentPromptCommand | undefined {
|
|
const trimmed = prompt.trim();
|
|
if (trimmed === "/compact") return "compact";
|
|
if (trimmed === "/clear") return "clear";
|
|
return undefined;
|
|
}
|
|
|
|
export function truncateAgentUserPrompt(prompt: string): string {
|
|
if (!prompt) return "";
|
|
if (prompt.length <= AGENT_USER_PROMPT_MAX_CHARS) return prompt;
|
|
const offset = utf8.offset(prompt, AGENT_USER_PROMPT_MAX_CHARS + 1);
|
|
if (offset === undefined) return prompt;
|
|
return string.sub(prompt, 1, offset - 1);
|
|
}
|
|
|
|
function canWriteStepLLMDebug(shared: AgentShared, stepId = shared.step + 1): boolean {
|
|
return App.debugging === true
|
|
&& shared.sessionId !== undefined
|
|
&& shared.sessionId > 0
|
|
&& shared.taskId > 0
|
|
&& stepId > 0;
|
|
}
|
|
|
|
function ensureDirRecursive(dir: string): boolean {
|
|
if (!dir) return false;
|
|
if (Content.exist(dir)) return Content.isdir(dir);
|
|
const parent = Path.getPath(dir);
|
|
if (parent && parent !== dir && !Content.exist(parent) && !ensureDirRecursive(parent)) {
|
|
return false;
|
|
}
|
|
return Content.mkdir(dir);
|
|
}
|
|
|
|
function encodeDebugJSON(value: unknown): string {
|
|
const [text, err] = safeJsonEncode(value as object);
|
|
return text ?? `{ "error": "json_encode_failed", "message": "${tostring(err)}" }`;
|
|
}
|
|
|
|
function getStepLLMDebugDir(shared: AgentShared): string {
|
|
return Path(
|
|
shared.workingDir,
|
|
".agent",
|
|
tostring(shared.sessionId as number),
|
|
tostring(shared.taskId),
|
|
);
|
|
}
|
|
|
|
function getStepLLMDebugPath(shared: AgentShared, stepId: number, seq: number, kind: "in" | "out"): string {
|
|
return Path(getStepLLMDebugDir(shared), `${tostring(stepId)}_${tostring(seq)}_${kind}.md`);
|
|
}
|
|
|
|
function getLatestStepLLMDebugSeq(shared: AgentShared, stepId: number): number {
|
|
if (!canWriteStepLLMDebug(shared, stepId)) return 0;
|
|
const dir = getStepLLMDebugDir(shared);
|
|
if (!Content.exist(dir) || !Content.isdir(dir)) return 0;
|
|
let latest = 0;
|
|
for (const file of Content.getFiles(dir)) {
|
|
const name = Path.getFilename(file);
|
|
const [seqText] = string.match(name, `^${tostring(stepId)}_(%d+)_in%.md$`);
|
|
if (seqText !== undefined) {
|
|
latest = math.max(latest, tonumber(seqText) as number);
|
|
continue;
|
|
}
|
|
const [legacyMatch] = string.match(name, `^${tostring(stepId)}_in%.md$`);
|
|
if (legacyMatch !== undefined) {
|
|
latest = math.max(latest, 1);
|
|
}
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
function writeStepLLMDebugFile(path: string, content: string): boolean {
|
|
if (!Content.save(path, content)) {
|
|
Log("Warn", `[CodingAgent] failed to save LLM debug file: ${path}`);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function createStepLLMDebugPair(shared: AgentShared, stepId: number, inContent: string): number {
|
|
if (!canWriteStepLLMDebug(shared, stepId)) return 0;
|
|
const dir = getStepLLMDebugDir(shared);
|
|
if (!ensureDirRecursive(dir)) {
|
|
Log("Warn", `[CodingAgent] failed to create LLM debug dir: ${dir}`);
|
|
return 0;
|
|
}
|
|
const seq = getLatestStepLLMDebugSeq(shared, stepId) + 1;
|
|
const inPath = getStepLLMDebugPath(shared, stepId, seq, "in");
|
|
const outPath = getStepLLMDebugPath(shared, stepId, seq, "out");
|
|
if (!writeStepLLMDebugFile(inPath, inContent)) {
|
|
return 0;
|
|
}
|
|
writeStepLLMDebugFile(outPath, "");
|
|
return seq;
|
|
}
|
|
|
|
function updateLatestStepLLMDebugOutput(shared: AgentShared, stepId: number, content: string): void {
|
|
if (!canWriteStepLLMDebug(shared, stepId)) return;
|
|
const dir = getStepLLMDebugDir(shared);
|
|
if (!ensureDirRecursive(dir)) {
|
|
Log("Warn", `[CodingAgent] failed to create LLM debug dir: ${dir}`);
|
|
return;
|
|
}
|
|
const latestSeq = getLatestStepLLMDebugSeq(shared, stepId);
|
|
if (latestSeq <= 0) {
|
|
const outPath = getStepLLMDebugPath(shared, stepId, 1, "out");
|
|
writeStepLLMDebugFile(outPath, content);
|
|
return;
|
|
}
|
|
const outPath = getStepLLMDebugPath(shared, stepId, latestSeq, "out");
|
|
writeStepLLMDebugFile(outPath, content);
|
|
}
|
|
|
|
function saveStepLLMDebugInput(shared: AgentShared, stepId: number, phase: string, messages: Message[], options: Record<string, unknown>): void {
|
|
if (!canWriteStepLLMDebug(shared, stepId)) return;
|
|
const sections: string[] = [
|
|
"# LLM Input",
|
|
`session_id: ${tostring(shared.sessionId as number)}`,
|
|
`task_id: ${tostring(shared.taskId)}`,
|
|
`step_id: ${tostring(stepId)}`,
|
|
`phase: ${phase}`,
|
|
`timestamp: ${os.date("!%Y-%m-%dT%H:%M:%SZ")}`,
|
|
"## Options",
|
|
"```json",
|
|
encodeDebugJSON(options),
|
|
"```",
|
|
];
|
|
for (let i = 0; i < messages.length; i++) {
|
|
const message = messages[i];
|
|
sections.push(`## Message ${i + 1}`);
|
|
sections.push(encodeDebugJSON(message));
|
|
}
|
|
createStepLLMDebugPair(shared, stepId, sections.join("\n"));
|
|
}
|
|
|
|
function saveStepLLMDebugOutput(shared: AgentShared, stepId: number, phase: string, text: string, meta?: Record<string, unknown>): void {
|
|
if (!canWriteStepLLMDebug(shared, stepId)) return;
|
|
const sections = [
|
|
"# LLM Output",
|
|
`session_id: ${tostring(shared.sessionId as number)}`,
|
|
`task_id: ${tostring(shared.taskId)}`,
|
|
`step_id: ${tostring(stepId)}`,
|
|
`phase: ${phase}`,
|
|
`timestamp: ${os.date("!%Y-%m-%dT%H:%M:%SZ")}`,
|
|
...(meta ? ["## Meta", "```json", encodeDebugJSON(meta), "```"] : []),
|
|
"## Content",
|
|
text,
|
|
];
|
|
updateLatestStepLLMDebugOutput(shared, stepId, sections.join("\n"));
|
|
}
|
|
|
|
function toJson(value: unknown): string {
|
|
const [text, err] = safeJsonEncode(value as object);
|
|
if (text !== undefined) return text;
|
|
return `{ "error": "json_encode_failed", "message": "${tostring(err)}" }`;
|
|
}
|
|
|
|
function truncateText(text: string, maxLen: number): string {
|
|
if (text.length <= maxLen) return text;
|
|
const nextPos = utf8.offset(text, maxLen + 1);
|
|
if (nextPos === undefined) return text;
|
|
return `${string.sub(text, 1, nextPos - 1)}...`;
|
|
}
|
|
|
|
function utf8TakeHead(text: string, maxChars: number): string {
|
|
if (maxChars <= 0 || text === "") return "";
|
|
const nextPos = utf8.offset(text, maxChars + 1);
|
|
if (nextPos === undefined) return text;
|
|
return string.sub(text, 1, nextPos - 1);
|
|
}
|
|
|
|
function getReplyLanguageDirective(shared: AgentShared): string {
|
|
return shared.useChineseResponse
|
|
? shared.promptPack.replyLanguageDirectiveZh
|
|
: shared.promptPack.replyLanguageDirectiveEn;
|
|
}
|
|
|
|
function replacePromptVars(template: string, vars: Record<string, string>): string {
|
|
let output = template;
|
|
for (const key in vars) {
|
|
output = output.split(`{{${key}}}`).join(vars[key] ?? "");
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function limitReadContentForHistory(content: string, tool: "read_file"): string {
|
|
const lines = content.split("\n");
|
|
const overLineLimit = lines.length > HISTORY_READ_FILE_MAX_LINES;
|
|
const limitedByLines = overLineLimit
|
|
? lines.slice(0, HISTORY_READ_FILE_MAX_LINES).join("\n")
|
|
: content;
|
|
if (limitedByLines.length <= HISTORY_READ_FILE_MAX_CHARS && !overLineLimit) {
|
|
return content;
|
|
}
|
|
const limited = limitedByLines.length > HISTORY_READ_FILE_MAX_CHARS
|
|
? utf8TakeHead(limitedByLines, HISTORY_READ_FILE_MAX_CHARS)
|
|
: limitedByLines;
|
|
const reasons: string[] = [];
|
|
if (content.length > HISTORY_READ_FILE_MAX_CHARS) reasons.push(`${content.length} chars`);
|
|
if (lines.length > HISTORY_READ_FILE_MAX_LINES) reasons.push(`${lines.length} lines`);
|
|
const hint = "Narrow the requested line range.";
|
|
return `[${tool} content truncated for history (${reasons.join(", ")}). ${hint}]\n${limited}`;
|
|
}
|
|
|
|
function summarizeEditTextParamForHistory(value: unknown, key: "old_str" | "new_str"): Record<string, unknown> | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const text = value;
|
|
const lineCount = text === "" ? 0 : text.split("\n").length;
|
|
return {
|
|
charCount: text.length,
|
|
lineCount,
|
|
isMultiline: lineCount > 1,
|
|
summaryType: `${key}_summary`,
|
|
};
|
|
}
|
|
|
|
function sanitizeReadResultForHistory(tool: AgentToolName, result: Record<string, unknown>): Record<string, unknown> {
|
|
if (tool !== "read_file" || result.success !== true || typeof result.content !== "string") {
|
|
return result;
|
|
}
|
|
const clone: Record<string, unknown> = {};
|
|
for (const key in result) {
|
|
clone[key] = result[key];
|
|
}
|
|
clone.content = limitReadContentForHistory(result.content, tool);
|
|
return clone;
|
|
}
|
|
|
|
function sanitizeSearchMatchesForHistory(
|
|
items: Record<string, unknown>[],
|
|
maxItems: number
|
|
): Record<string, unknown>[] {
|
|
const shown = math.min(items.length, maxItems);
|
|
const out: Record<string, unknown>[] = [];
|
|
for (let i = 0; i < shown; i++) {
|
|
const row = items[i];
|
|
out.push({
|
|
file: row.file,
|
|
line: row.line,
|
|
content: typeof row.content === "string"
|
|
? truncateText(row.content, 240)
|
|
: row.content,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function sanitizeSearchResultForHistory(
|
|
tool: AgentToolName,
|
|
result: Record<string, unknown>
|
|
): Record<string, unknown> {
|
|
if (result.success !== true || !isArray(result.results)) return result;
|
|
if (tool !== "grep_files" && tool !== "search_dora_api") return result;
|
|
const clone: Record<string, unknown> = {};
|
|
for (const key in result) {
|
|
clone[key] = result[key];
|
|
}
|
|
const maxItems = tool === "grep_files" ? HISTORY_SEARCH_FILES_MAX_MATCHES : HISTORY_SEARCH_DORA_API_MAX_MATCHES;
|
|
clone.results = sanitizeSearchMatchesForHistory(
|
|
result.results,
|
|
maxItems
|
|
);
|
|
if (tool === "grep_files" && isArray(result.groupedResults)) {
|
|
const grouped = result.groupedResults;
|
|
const shown = math.min(grouped.length, HISTORY_SEARCH_FILES_MAX_MATCHES);
|
|
const sanitizedGroups: Record<string, unknown>[] = [];
|
|
for (let i = 0; i < shown; i++) {
|
|
const row = grouped[i];
|
|
sanitizedGroups.push({
|
|
file: row.file,
|
|
totalMatches: row.totalMatches,
|
|
matches: isArray(row.matches)
|
|
? sanitizeSearchMatchesForHistory(row.matches, 3)
|
|
: [],
|
|
});
|
|
}
|
|
clone.groupedResults = sanitizedGroups;
|
|
}
|
|
return clone;
|
|
}
|
|
|
|
function sanitizeListFilesResultForHistory(result: Record<string, unknown>): Record<string, unknown> {
|
|
if (result.success !== true || !isArray(result.files)) return result;
|
|
const clone: Record<string, unknown> = {};
|
|
for (const key in result) {
|
|
clone[key] = result[key];
|
|
}
|
|
clone.files = result.files.slice(0, HISTORY_LIST_FILES_MAX_ENTRIES);
|
|
return clone;
|
|
}
|
|
|
|
function sanitizeBuildResultForHistory(result: Record<string, unknown>): Record<string, unknown> {
|
|
if (!isArray(result.messages)) return result;
|
|
const clone: Record<string, unknown> = {};
|
|
for (const key in result) {
|
|
clone[key] = result[key];
|
|
}
|
|
const messages = result.messages as Record<string, unknown>[];
|
|
const shown = math.min(messages.length, HISTORY_BUILD_MAX_MESSAGES);
|
|
const sanitized: Record<string, unknown>[] = [];
|
|
for (let i = 0; i < shown; i++) {
|
|
const item = messages[i];
|
|
const next: Record<string, unknown> = {};
|
|
for (const key in item) {
|
|
const value = item[key];
|
|
next[key] = key === "message" && typeof value === "string"
|
|
? truncateText(value, HISTORY_BUILD_MESSAGE_MAX_CHARS)
|
|
: value;
|
|
}
|
|
sanitized.push(next);
|
|
}
|
|
clone.messages = sanitized;
|
|
if (messages.length > shown) {
|
|
clone.truncatedMessages = messages.length - shown;
|
|
}
|
|
return clone;
|
|
}
|
|
|
|
function sanitizeActionParamsForHistory(tool: AgentToolName, params: Record<string, unknown>): Record<string, unknown> {
|
|
if (tool !== "edit_file") return params;
|
|
const clone: Record<string, unknown> = {};
|
|
for (const key in params) {
|
|
if (key === "old_str") {
|
|
clone.old_str_stats = summarizeEditTextParamForHistory(params[key], "old_str");
|
|
} else if (key === "new_str") {
|
|
clone.new_str_stats = summarizeEditTextParamForHistory(params[key], "new_str");
|
|
} else {
|
|
clone[key] = params[key];
|
|
}
|
|
}
|
|
return clone;
|
|
}
|
|
|
|
function getDecisionToolDefinitions(shared?: AgentShared): string {
|
|
const base = replacePromptVars(
|
|
shared?.promptPack.toolDefinitionsDetailed ?? "",
|
|
{ SEARCH_DORA_API_LIMIT_MAX: tostring(SEARCH_DORA_API_LIMIT_MAX) }
|
|
);
|
|
const spawnTool = `
|
|
|
|
9. list_sub_agents: Query sub-agent state under the current main session
|
|
- Parameters: status(optional), limit(optional), offset(optional), query(optional)
|
|
- Use this only when you do not already know the current sub-agent status and need to inspect running delegated work or recent completed results before deciding whether to dispatch more sub agents or read a result file.
|
|
- status defaults to active_or_recent and may also be running, done, failed, or all.
|
|
- limit defaults to a small recent window. Use offset to page older items.
|
|
- query filters by title, goal, or summary text.
|
|
- Do not use this after a successful spawn_sub_agent in the same turn.
|
|
|
|
10. spawn_sub_agent: Create and start a sub agent session for delegated implementation work
|
|
- Parameters: title, prompt, expectedOutput(optional), filesHint(optional)
|
|
- Use this for large multi-file work, parallel exploration, long-running verification, or isolated execution tasks.
|
|
- For small focused edits, use edit_file/delete_file/build directly in the current main-agent run.
|
|
- The spawned sub agent can use read_file, edit_file, delete_file, grep_files, search_dora_api, glob_files, build, and finish.
|
|
- title should be short and specific.
|
|
- prompt should be self-contained and actionable, and should clearly describe the concrete work to execute, constraints, desired output, and any relevant files.
|
|
- If spawn succeeds, immediately finish the current turn and state that the work has been delegated.
|
|
- Do not call list_sub_agents or any other tool after a successful spawn_sub_agent in the same turn.
|
|
- Treat the actual implementation result as an asynchronous handoff that will be handled in later conversation turns.
|
|
- filesHint is an optional list of likely files or directories.`;
|
|
const availability = shared
|
|
? `\n\nTool availability for this runtime:\n- role: ${shared.role}\n- allowed tools: ${getAllowedToolsForRole(shared.role).join(", ")}`
|
|
: "";
|
|
const withRole = `${base}${shared?.role === "main" ? spawnTool : ""}${availability}`;
|
|
if (shared?.decisionMode !== "xml") {
|
|
return withRole;
|
|
}
|
|
return `${withRole}
|
|
|
|
XML mode object fields:
|
|
- Use a single root tag: <tool_call>.
|
|
- For read_file, edit_file, delete_file, grep_files, search_dora_api, glob_files, and build, include <tool>, <reason>, and <params>.
|
|
- For finish, do not include <reason>. Use only <tool> and <params><message>...</message></params>.
|
|
- Inside <params>, use one child tag per parameter and preserve each tag content as raw text.`;
|
|
}
|
|
|
|
function isToolAllowedForRole(role: AgentRole, tool: AgentToolName): boolean {
|
|
return getAllowedToolsForRole(role).indexOf(tool) >= 0;
|
|
}
|
|
|
|
const PRE_EXEC_SAFE_TOOLS: AgentToolName[] = [
|
|
"read_file",
|
|
"grep_files",
|
|
"search_dora_api",
|
|
"glob_files",
|
|
"list_sub_agents",
|
|
];
|
|
|
|
function canPreExecuteTool(tool: AgentToolName): boolean {
|
|
return PRE_EXEC_SAFE_TOOLS.indexOf(tool) >= 0;
|
|
}
|
|
|
|
function clearPreExecutedResults(shared: AgentShared): void {
|
|
shared.preExecutedResults = undefined;
|
|
}
|
|
|
|
async function startPreExecutedToolAction(shared: AgentShared, action: AgentActionRecord): Promise<Record<string, unknown>> {
|
|
try {
|
|
return await executeToolAction(shared, action);
|
|
} catch (err) {
|
|
const message = tostring(err);
|
|
Log("Error", `[CodingAgent] streaming pre-exec failed tool=${action.tool} id=${action.toolCallId}: ${message}`);
|
|
return { success: false, message };
|
|
}
|
|
}
|
|
|
|
function createPreExecutedToolResult(shared: AgentShared, action: AgentActionRecord): PreExecutedToolResult {
|
|
const cloneParamValue = (value: unknown): unknown => {
|
|
if (value === undefined || value === null) return value;
|
|
if (isArray(value)) {
|
|
return value.map(item => cloneParamValue(item));
|
|
}
|
|
if (typeof value === "object") {
|
|
const clone: Record<string, unknown> = {};
|
|
for (const key in value as Record<string, unknown>) {
|
|
clone[key] = cloneParamValue((value as Record<string, unknown>)[key]);
|
|
}
|
|
return clone;
|
|
}
|
|
return value;
|
|
};
|
|
const params = cloneParamValue(action.params) as Record<string, unknown>;
|
|
const areParamValuesEqual = (left: unknown, right: unknown): boolean => {
|
|
if (left === right) return true;
|
|
if (left === undefined || left === null || right === undefined || right === null) return false;
|
|
if (isArray(left) || isArray(right)) {
|
|
if (!isArray(left) || !isArray(right) || left.length !== right.length) return false;
|
|
for (let i = 0; i < left.length; i++) {
|
|
if (!areParamValuesEqual(left[i], right[i])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
if (typeof left === "object" && typeof right === "object") {
|
|
let leftCount = 0;
|
|
for (const key in left as Record<string, unknown>) {
|
|
leftCount++;
|
|
if (!areParamValuesEqual(
|
|
(left as Record<string, unknown>)[key],
|
|
(right as Record<string, unknown>)[key]
|
|
)) {
|
|
return false;
|
|
}
|
|
}
|
|
let rightCount = 0;
|
|
for (const key in right as Record<string, unknown>) {
|
|
rightCount++;
|
|
}
|
|
return leftCount === rightCount;
|
|
}
|
|
return false;
|
|
};
|
|
return {
|
|
matches(nextAction: AgentActionRecord): boolean {
|
|
return action.tool === nextAction.tool && areParamValuesEqual(params, nextAction.params);
|
|
},
|
|
promise: startPreExecutedToolAction(shared, action),
|
|
};
|
|
}
|
|
|
|
async function executeToolActionWithPreExecution(shared: AgentShared, action: AgentActionRecord): Promise<Record<string, unknown>> {
|
|
const preResult = shared.preExecutedResults?.get(action.toolCallId);
|
|
if (preResult) {
|
|
shared.preExecutedResults?.delete(action.toolCallId);
|
|
if (preResult.matches(action)) {
|
|
Log("Info", `[CodingAgent] using streaming pre-exec result tool=${action.tool} id=${action.toolCallId}`);
|
|
return await preResult.promise;
|
|
}
|
|
Log("Warn", `[CodingAgent] discard stale streaming pre-exec result tool=${action.tool} id=${action.toolCallId}`);
|
|
}
|
|
return executeToolAction(shared, action);
|
|
}
|
|
|
|
async function maybeCompressHistory(shared: AgentShared): Promise<void> {
|
|
const { memory } = shared;
|
|
const maxRounds = memory.compressor.getMaxCompressionRounds();
|
|
let changed = false;
|
|
for (let round = 0; round < maxRounds; round++) {
|
|
const systemPrompt = buildAgentSystemPrompt(shared, shared.decisionMode === "xml");
|
|
const activeMessages = getActiveConversationMessages(shared);
|
|
// In tool_calling mode, tool definitions are passed as the tools API parameter and
|
|
// consume tokens, but are NOT embedded in the system prompt. Pass them separately
|
|
// so the token estimator accounts for them correctly.
|
|
const toolDefinitions = shared.decisionMode === "tool_calling"
|
|
? getDecisionToolDefinitions(shared)
|
|
: "";
|
|
if (!memory.compressor.shouldCompress(
|
|
activeMessages,
|
|
systemPrompt,
|
|
toolDefinitions
|
|
)) {
|
|
if (changed) {
|
|
persistHistoryState(shared);
|
|
}
|
|
return;
|
|
}
|
|
const compressionRound = round + 1;
|
|
shared.step += 1;
|
|
const stepId = shared.step;
|
|
const pendingMessages = activeMessages.length;
|
|
emitAgentEvent(shared, {
|
|
type: "memory_compression_started",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step: stepId,
|
|
tool: "compress_memory",
|
|
reason: getMemoryCompressionStartReason(shared),
|
|
params: {
|
|
round: compressionRound,
|
|
maxRounds,
|
|
pendingMessages,
|
|
},
|
|
});
|
|
const result = await memory.compressor.compress(
|
|
activeMessages,
|
|
shared.llmOptions,
|
|
shared.llmMaxTry,
|
|
shared.decisionMode,
|
|
{
|
|
onInput: (phase, messages, options) => {
|
|
saveStepLLMDebugInput(shared, stepId, phase, messages, options);
|
|
},
|
|
onOutput: (phase, text, meta) => {
|
|
saveStepLLMDebugOutput(shared, stepId, phase, text, meta);
|
|
},
|
|
},
|
|
"default",
|
|
systemPrompt,
|
|
toolDefinitions
|
|
);
|
|
if (!(result && result.success && result.compressedCount > 0)) {
|
|
emitAgentEvent(shared, {
|
|
type: "memory_compression_finished",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step: stepId,
|
|
tool: "compress_memory",
|
|
reason: getMemoryCompressionFailureReason(
|
|
shared,
|
|
result?.error ?? "compression returned no changes"
|
|
),
|
|
result: {
|
|
success: false,
|
|
round: compressionRound,
|
|
error: result?.error ?? "compression returned no changes",
|
|
compressedCount: result?.compressedCount ?? 0,
|
|
},
|
|
});
|
|
if (changed) {
|
|
persistHistoryState(shared);
|
|
}
|
|
return;
|
|
}
|
|
const effectiveCompressedCount = math.max(
|
|
0,
|
|
result.compressedCount - (typeof shared.carryMessageIndex === "number" ? 1 : 0)
|
|
);
|
|
if (effectiveCompressedCount <= 0) {
|
|
if (changed) {
|
|
persistHistoryState(shared);
|
|
}
|
|
return;
|
|
}
|
|
emitAgentEvent(shared, {
|
|
type: "memory_compression_finished",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step: stepId,
|
|
tool: "compress_memory",
|
|
reason: getMemoryCompressionSuccessReason(shared, result.compressedCount),
|
|
result: {
|
|
success: true,
|
|
round: compressionRound,
|
|
compressedCount: effectiveCompressedCount,
|
|
historyEntryPreview: summarizeHistoryEntryPreview(result.summary ?? ""),
|
|
},
|
|
});
|
|
applyCompressedSessionState(shared, result.compressedCount, result.carryMessageIndex);
|
|
changed = true;
|
|
Log("Info", `[Memory] Compressed ${effectiveCompressedCount} messages (round ${compressionRound})`);
|
|
}
|
|
if (changed) {
|
|
persistHistoryState(shared);
|
|
}
|
|
}
|
|
|
|
async function compactAllHistory(shared: AgentShared): Promise<CodingAgentRunResult> {
|
|
const { memory } = shared;
|
|
let rounds = 0;
|
|
let totalCompressed = 0;
|
|
while (getActiveRealMessageCount(shared) > 0) {
|
|
if (shared.stopToken.stopped) {
|
|
Tools.setTaskStatus(shared.taskId, "STOPPED");
|
|
return emitAgentTaskFinishEvent(shared, false, getCancelledReason(shared));
|
|
}
|
|
rounds += 1;
|
|
shared.step += 1;
|
|
const stepId = shared.step;
|
|
const activeMessages = getActiveConversationMessages(shared);
|
|
const pendingMessages = activeMessages.length;
|
|
emitAgentEvent(shared, {
|
|
type: "memory_compression_started",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step: stepId,
|
|
tool: "compress_memory",
|
|
reason: getMemoryCompressionStartReason(shared),
|
|
params: {
|
|
round: rounds,
|
|
maxRounds: 0,
|
|
pendingMessages,
|
|
fullCompaction: true,
|
|
},
|
|
});
|
|
const result = await memory.compressor.compress(
|
|
activeMessages,
|
|
shared.llmOptions,
|
|
shared.llmMaxTry,
|
|
shared.decisionMode,
|
|
{
|
|
onInput: (phase, messages, options) => {
|
|
saveStepLLMDebugInput(shared, stepId, phase, messages, options);
|
|
},
|
|
onOutput: (phase, text, meta) => {
|
|
saveStepLLMDebugOutput(shared, stepId, phase, text, meta);
|
|
},
|
|
},
|
|
"budget_max"
|
|
);
|
|
if (!(result && result.success && result.compressedCount > 0)) {
|
|
emitAgentEvent(shared, {
|
|
type: "memory_compression_finished",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step: stepId,
|
|
tool: "compress_memory",
|
|
reason: getMemoryCompressionFailureReason(
|
|
shared,
|
|
result?.error ?? "compression returned no changes"
|
|
),
|
|
result: {
|
|
success: false,
|
|
rounds,
|
|
error: result?.error ?? "compression returned no changes",
|
|
compressedCount: result?.compressedCount ?? 0,
|
|
fullCompaction: true,
|
|
},
|
|
});
|
|
return finalizeAgentFailure(shared,
|
|
result?.error ?? (shared.useChineseResponse
|
|
? "记忆压缩未产生可推进的结果。"
|
|
: "Memory compression produced no progress."));
|
|
}
|
|
const effectiveCompressedCount = math.max(
|
|
0,
|
|
result.compressedCount - (typeof shared.carryMessageIndex === "number" ? 1 : 0)
|
|
);
|
|
if (effectiveCompressedCount <= 0) {
|
|
return finalizeAgentFailure(
|
|
shared,
|
|
shared.useChineseResponse
|
|
? "记忆压缩未产生可推进的结果。"
|
|
: "Memory compression produced no progress."
|
|
);
|
|
}
|
|
emitAgentEvent(shared, {
|
|
type: "memory_compression_finished",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step: stepId,
|
|
tool: "compress_memory",
|
|
reason: getMemoryCompressionSuccessReason(shared, result.compressedCount),
|
|
result: {
|
|
success: true,
|
|
round: rounds,
|
|
compressedCount: effectiveCompressedCount,
|
|
historyEntryPreview: summarizeHistoryEntryPreview(result.summary ?? ""),
|
|
fullCompaction: true,
|
|
},
|
|
});
|
|
applyCompressedSessionState(shared, result.compressedCount, result.carryMessageIndex);
|
|
totalCompressed += effectiveCompressedCount;
|
|
persistHistoryState(shared);
|
|
Log("Info", `[Memory] Full compaction compressed ${effectiveCompressedCount} messages (round ${rounds})`);
|
|
}
|
|
Tools.setTaskStatus(shared.taskId, "DONE");
|
|
return emitAgentTaskFinishEvent(
|
|
shared,
|
|
true,
|
|
shared.useChineseResponse
|
|
? `会话整理完成,共整理 ${totalCompressed} 条消息,耗时 ${rounds} 轮。`
|
|
: `Session compaction completed. Consolidated ${totalCompressed} messages in ${rounds} rounds.`
|
|
);
|
|
}
|
|
|
|
function clearSessionHistory(shared: AgentShared): CodingAgentRunResult {
|
|
shared.messages = [];
|
|
shared.lastConsolidatedIndex = 0;
|
|
shared.carryMessageIndex = undefined;
|
|
persistHistoryState(shared);
|
|
Tools.setTaskStatus(shared.taskId, "DONE");
|
|
return emitAgentTaskFinishEvent(
|
|
shared,
|
|
true,
|
|
shared.useChineseResponse
|
|
? "SESSION.jsonl 已清空。"
|
|
: "SESSION.jsonl has been cleared."
|
|
);
|
|
}
|
|
|
|
function isKnownToolName(name: string): name is AgentToolName {
|
|
return name === "read_file"
|
|
|| name === "edit_file"
|
|
|| name === "delete_file"
|
|
|| name === "grep_files"
|
|
|| name === "search_dora_api"
|
|
|| name === "glob_files"
|
|
|| name === "build"
|
|
|| name === "list_sub_agents"
|
|
|| name === "spawn_sub_agent"
|
|
|| name === "finish";
|
|
}
|
|
|
|
function getFinishMessage(params: Record<string, unknown>, fallback = ""): string {
|
|
if (typeof params.message === "string" && params.message.trim() !== "") {
|
|
return params.message.trim();
|
|
}
|
|
if (typeof params.response === "string" && params.response.trim() !== "") {
|
|
return params.response.trim();
|
|
}
|
|
if (typeof params.summary === "string" && params.summary.trim() !== "") {
|
|
return params.summary.trim();
|
|
}
|
|
return fallback.trim();
|
|
}
|
|
|
|
function persistHistoryState(shared: AgentShared): void {
|
|
shared.memory.compressor.getStorage().writeSessionState(
|
|
shared.messages,
|
|
shared.lastConsolidatedIndex,
|
|
shared.carryMessageIndex
|
|
);
|
|
}
|
|
|
|
function getActiveConversationMessages(shared: AgentShared): AgentConversationMessage[] {
|
|
const activeMessages: AgentConversationMessage[] = [];
|
|
if (
|
|
typeof shared.carryMessageIndex === "number"
|
|
&& shared.carryMessageIndex >= 0
|
|
&& shared.carryMessageIndex < shared.lastConsolidatedIndex
|
|
&& shared.carryMessageIndex < shared.messages.length
|
|
) {
|
|
activeMessages.push({
|
|
...shared.messages[shared.carryMessageIndex],
|
|
});
|
|
}
|
|
for (let i = shared.lastConsolidatedIndex; i < shared.messages.length; i++) {
|
|
activeMessages.push(shared.messages[i]);
|
|
}
|
|
return activeMessages;
|
|
}
|
|
|
|
function getActiveRealMessageCount(shared: AgentShared): number {
|
|
return math.max(0, shared.messages.length - shared.lastConsolidatedIndex);
|
|
}
|
|
|
|
function applyCompressedSessionState(
|
|
shared: AgentShared,
|
|
compressedCount: number,
|
|
carryMessageIndex?: number
|
|
): void {
|
|
const syntheticPrefixCount = typeof shared.carryMessageIndex === "number" ? 1 : 0;
|
|
const previousActiveStart = shared.lastConsolidatedIndex;
|
|
const realCompressedCount = math.max(0, compressedCount - syntheticPrefixCount);
|
|
shared.lastConsolidatedIndex = math.min(
|
|
shared.messages.length,
|
|
previousActiveStart + realCompressedCount
|
|
);
|
|
if (typeof carryMessageIndex === "number") {
|
|
if (syntheticPrefixCount > 0 && carryMessageIndex === 0) {
|
|
// Keep the previously carried user message.
|
|
} else {
|
|
const carryOffset = syntheticPrefixCount > 0
|
|
? carryMessageIndex - 1
|
|
: carryMessageIndex;
|
|
shared.carryMessageIndex = carryOffset >= 0
|
|
? previousActiveStart + carryOffset
|
|
: undefined;
|
|
}
|
|
} else {
|
|
shared.carryMessageIndex = undefined;
|
|
}
|
|
if (
|
|
typeof shared.carryMessageIndex === "number"
|
|
&& (
|
|
shared.carryMessageIndex < 0
|
|
|| shared.carryMessageIndex >= shared.lastConsolidatedIndex
|
|
|| shared.carryMessageIndex >= shared.messages.length
|
|
)
|
|
) {
|
|
shared.carryMessageIndex = undefined;
|
|
}
|
|
}
|
|
|
|
function appendConversationMessage(shared: AgentShared, message: AgentConversationMessage): void {
|
|
shared.messages.push({
|
|
...message,
|
|
content: message.content ? sanitizeUTF8(message.content) : message.content,
|
|
name: message.name ? sanitizeUTF8(message.name) : message.name,
|
|
tool_call_id: message.tool_call_id ? sanitizeUTF8(message.tool_call_id) : message.tool_call_id,
|
|
reasoning_content: message.reasoning_content ? sanitizeUTF8(message.reasoning_content) : message.reasoning_content,
|
|
timestamp: message.timestamp ?? os.date("!%Y-%m-%dT%H:%M:%SZ"),
|
|
});
|
|
}
|
|
|
|
function ensureToolCallId(toolCallId?: string): string {
|
|
if (toolCallId && toolCallId !== "") return toolCallId;
|
|
return createLocalToolCallId();
|
|
}
|
|
|
|
function appendToolResultMessage(shared: AgentShared, action: AgentActionRecord): void {
|
|
appendConversationMessage(shared, {
|
|
role: "tool",
|
|
tool_call_id: action.toolCallId,
|
|
name: action.tool,
|
|
content: action.result ? toJson(action.result) : "",
|
|
});
|
|
}
|
|
|
|
function appendAssistantToolCallsMessage(
|
|
shared: AgentShared,
|
|
actions: AgentActionRecord[],
|
|
content?: string,
|
|
reasoningContent?: string
|
|
): void {
|
|
appendConversationMessage(shared, {
|
|
role: "assistant",
|
|
content: content ?? "",
|
|
reasoning_content: reasoningContent,
|
|
tool_calls: actions.map(action => ({
|
|
id: action.toolCallId,
|
|
type: "function",
|
|
function: {
|
|
name: action.tool,
|
|
arguments: toJson(action.params),
|
|
},
|
|
})),
|
|
});
|
|
}
|
|
|
|
function parseXMLToolCallObjectFromText(text: string): { success: true; obj: Record<string, unknown> } | { success: false; message: string } {
|
|
const children = parseXMLObjectFromText(text, "tool_call");
|
|
if (!children.success) return children;
|
|
const rawObj = children.obj;
|
|
const paramsText = typeof rawObj.params === "string" ? rawObj.params as string : "";
|
|
const params = paramsText !== ""
|
|
? parseSimpleXMLChildren(paramsText)
|
|
: { success: true as const, obj: {} as Record<string, unknown> };
|
|
if (!params.success) {
|
|
return { success: false, message: params.message };
|
|
}
|
|
return {
|
|
success: true,
|
|
obj: {
|
|
tool: rawObj.tool,
|
|
reason: rawObj.reason,
|
|
params: params.obj,
|
|
},
|
|
};
|
|
}
|
|
|
|
type LLMResult = {
|
|
success: true;
|
|
text: string;
|
|
reasoningContent?: string
|
|
} | {
|
|
success: false;
|
|
message: string;
|
|
text?: string
|
|
};
|
|
|
|
async function llm(
|
|
shared: AgentShared,
|
|
messages: Message[],
|
|
phase: "decision_xml" | "decision_xml_repair" = "decision_xml"
|
|
): Promise<LLMResult> {
|
|
const stepId = shared.step + 1;
|
|
emitLLMContextMetrics(shared, stepId, phase, messages, shared.llmOptions);
|
|
saveStepLLMDebugInput(shared, stepId, phase, messages, shared.llmOptions);
|
|
const res = await callLLM(messages, shared.llmOptions, shared.stopToken, shared.llmConfig);
|
|
if (res.success) {
|
|
const message = res.response.choices?.[0]?.message;
|
|
const text = message?.content;
|
|
const reasoningContent = typeof message?.reasoning_content === "string"
|
|
? sanitizeUTF8(message.reasoning_content)
|
|
: undefined;
|
|
if (text) {
|
|
saveStepLLMDebugOutput(shared, stepId, phase, text, { success: true });
|
|
return { success: true, text, reasoningContent };
|
|
} else {
|
|
saveStepLLMDebugOutput(shared, stepId, phase, "empty LLM response", { success: false });
|
|
return { success: false, message: "empty LLM response" };
|
|
}
|
|
} else {
|
|
saveStepLLMDebugOutput(shared, stepId, phase, res.raw ?? res.message, { success: false });
|
|
return { success: false, message: res.message };
|
|
}
|
|
}
|
|
|
|
type DecisionSuccess = {
|
|
success: true;
|
|
tool: AgentToolName;
|
|
params: Record<string, unknown>;
|
|
toolCallId?: string;
|
|
reason?: string;
|
|
reasoningContent?: string;
|
|
directSummary?: string;
|
|
};
|
|
type DecisionBatchSuccess = {
|
|
success: true;
|
|
kind: "batch";
|
|
decisions: DecisionSuccess[];
|
|
content?: string;
|
|
reasoningContent?: string;
|
|
};
|
|
type DecisionResult = DecisionSuccess | DecisionBatchSuccess | DecisionFailure;
|
|
type DecisionFailure = { success: false; message: string; raw?: string };
|
|
|
|
function isDecisionBatchSuccess(result: DecisionSuccess | DecisionBatchSuccess): result is DecisionBatchSuccess {
|
|
return (result as DecisionBatchSuccess).kind === "batch";
|
|
}
|
|
|
|
function parseDecisionObject(rawObj: Record<string, unknown>): DecisionSuccess | DecisionFailure {
|
|
if (typeof rawObj.tool !== "string") return { success: false, message: "missing tool" };
|
|
const tool = rawObj.tool;
|
|
if (!isKnownToolName(tool)) {
|
|
return { success: false, message: `unknown tool: ${tool}` };
|
|
}
|
|
const reason = typeof rawObj.reason === "string"
|
|
? rawObj.reason.trim()
|
|
: undefined;
|
|
if (tool !== "finish" && (!reason || reason === "")) {
|
|
return { success: false, message: `${tool} requires top-level reason` };
|
|
}
|
|
const params = isRecord(rawObj.params) ? rawObj.params : {};
|
|
return {
|
|
success: true,
|
|
tool,
|
|
params,
|
|
reason,
|
|
};
|
|
}
|
|
|
|
function parseDecisionToolCall(functionName: string, rawObj: unknown): DecisionSuccess | DecisionFailure {
|
|
if (!isKnownToolName(functionName)) {
|
|
return { success: false, message: `unknown tool: ${functionName}` };
|
|
}
|
|
if (rawObj === undefined || rawObj === null) {
|
|
return { success: true, tool: functionName, params: {} };
|
|
}
|
|
if (!isRecord(rawObj)) {
|
|
return { success: false, message: `invalid ${functionName} arguments` };
|
|
}
|
|
return {
|
|
success: true,
|
|
tool: functionName,
|
|
params: rawObj,
|
|
};
|
|
}
|
|
|
|
function parseToolCallArguments(functionName: string, argsText: string): Record<string, unknown> | DecisionFailure {
|
|
const trimmedArgs = argsText.trim();
|
|
if (trimmedArgs === "") {
|
|
return {};
|
|
}
|
|
const [rawObj, err] = safeJsonDecode(trimmedArgs);
|
|
if (err !== undefined || rawObj === undefined) {
|
|
return {
|
|
success: false,
|
|
message: `invalid ${functionName} arguments: ${tostring(err)}`,
|
|
raw: argsText,
|
|
};
|
|
}
|
|
const [encodedRaw] = safeJsonEncode(rawObj as object);
|
|
if (encodedRaw === "null" || !isRecord(rawObj) || trimmedArgs[0] === "[") {
|
|
return {
|
|
success: false,
|
|
message: `invalid ${functionName} arguments`,
|
|
raw: argsText,
|
|
};
|
|
}
|
|
return rawObj;
|
|
}
|
|
|
|
function parseAndValidateToolCallDecision(
|
|
shared: AgentShared,
|
|
functionName: string,
|
|
argsText: string,
|
|
toolCallId?: string,
|
|
reason?: string,
|
|
reasoningContent?: string
|
|
): DecisionSuccess | DecisionFailure {
|
|
const rawArgs = parseToolCallArguments(functionName, argsText);
|
|
if (isRecord(rawArgs) && rawArgs.success === false) {
|
|
return rawArgs as DecisionFailure;
|
|
}
|
|
const decision = parseDecisionToolCall(functionName, rawArgs);
|
|
if (!decision.success) {
|
|
return {
|
|
success: false,
|
|
message: decision.message,
|
|
raw: argsText,
|
|
};
|
|
}
|
|
const validation = validateDecision(decision.tool, decision.params);
|
|
if (!validation.success) {
|
|
return {
|
|
success: false,
|
|
message: validation.message,
|
|
raw: argsText,
|
|
};
|
|
}
|
|
if (!isToolAllowedForRole(shared.role, decision.tool)) {
|
|
return {
|
|
success: false,
|
|
message: `${decision.tool} is not allowed for role ${shared.role}`,
|
|
raw: argsText,
|
|
};
|
|
}
|
|
decision.params = validation.params;
|
|
decision.toolCallId = ensureToolCallId(toolCallId);
|
|
decision.reason = reason;
|
|
decision.reasoningContent = reasoningContent;
|
|
return decision;
|
|
}
|
|
|
|
function createPreExecutableActionFromStream(shared: AgentShared, toolCall: ToolCall): AgentActionRecord | undefined {
|
|
const functionName = toolCall.function?.name;
|
|
const argsText = toolCall.function?.arguments ?? "";
|
|
const toolCallId = typeof toolCall.id === "string" ? toolCall.id : undefined;
|
|
if (!functionName || !toolCallId) return undefined;
|
|
const rawArgs = parseToolCallArguments(functionName, argsText);
|
|
if (isRecord(rawArgs) && rawArgs.success === false) return undefined;
|
|
const decision = parseDecisionToolCall(functionName, rawArgs);
|
|
if (!decision.success || !canPreExecuteTool(decision.tool)) return undefined;
|
|
const validation = validateDecision(decision.tool, decision.params);
|
|
if (!validation.success) return undefined;
|
|
if (!isToolAllowedForRole(shared.role, decision.tool)) return undefined;
|
|
return {
|
|
step: shared.step + 1,
|
|
toolCallId,
|
|
tool: decision.tool,
|
|
reason: "",
|
|
params: validation.params,
|
|
timestamp: os.date("!%Y-%m-%dT%H:%M:%SZ"),
|
|
};
|
|
}
|
|
|
|
function getDecisionPath(params: Record<string, unknown>): string {
|
|
if (typeof params.path === "string") return params.path.trim();
|
|
if (typeof params.target_file === "string") return params.target_file.trim();
|
|
return "";
|
|
}
|
|
|
|
function clampIntegerParam(value: unknown, fallback: number, minValue: number, maxValue?: number): number {
|
|
let num = Number(value);
|
|
if (!Number.isFinite(num)) num = fallback;
|
|
num = math.floor(num);
|
|
if (num < minValue) num = minValue;
|
|
if (maxValue !== undefined && num > maxValue) num = maxValue;
|
|
return num;
|
|
}
|
|
|
|
function parseReadLineParam(
|
|
value: unknown,
|
|
fallback: number,
|
|
paramName: "startLine" | "endLine"
|
|
): { success: true; value: number } | { success: false; message: string } {
|
|
let num = Number(value);
|
|
if (!Number.isFinite(num)) num = fallback;
|
|
num = math.floor(num);
|
|
if (num === 0) {
|
|
return { success: false, message: `${paramName} cannot be 0` };
|
|
}
|
|
return { success: true, value: num };
|
|
}
|
|
|
|
function validateDecision(
|
|
tool: AgentToolName,
|
|
params: Record<string, unknown>
|
|
): { success: true; params: Record<string, unknown> } | { success: false; message: string } {
|
|
if (tool === "finish") {
|
|
const message = getFinishMessage(params);
|
|
if (message === "") return { success: false, message: "finish requires params.message" };
|
|
params.message = message;
|
|
return { success: true, params };
|
|
}
|
|
|
|
if (tool === "read_file") {
|
|
const path = getDecisionPath(params);
|
|
if (path === "") return { success: false, message: "read_file requires path" };
|
|
params.path = path;
|
|
const startLineRes = parseReadLineParam(params.startLine, 1, "startLine");
|
|
if (!startLineRes.success) return startLineRes;
|
|
const endLineDefault = startLineRes.value < 0 ? -1 : READ_FILE_DEFAULT_LIMIT;
|
|
const endLineRes = parseReadLineParam(params.endLine, endLineDefault, "endLine");
|
|
if (!endLineRes.success) return endLineRes;
|
|
params.startLine = startLineRes.value;
|
|
params.endLine = endLineRes.value;
|
|
return { success: true, params };
|
|
}
|
|
|
|
if (tool === "edit_file") {
|
|
const path = getDecisionPath(params);
|
|
if (path === "") return { success: false, message: "edit_file requires path" };
|
|
const oldStr = typeof params.old_str === "string" ? params.old_str : "";
|
|
const newStr = typeof params.new_str === "string" ? params.new_str : "";
|
|
params.path = path;
|
|
params.old_str = oldStr;
|
|
params.new_str = newStr;
|
|
return { success: true, params };
|
|
}
|
|
|
|
if (tool === "delete_file") {
|
|
const targetFile = getDecisionPath(params);
|
|
if (targetFile === "") return { success: false, message: "delete_file requires target_file" };
|
|
params.target_file = targetFile;
|
|
return { success: true, params };
|
|
}
|
|
|
|
if (tool === "grep_files") {
|
|
const pattern = typeof params.pattern === "string" ? params.pattern.trim() : "";
|
|
if (pattern === "") return { success: false, message: "grep_files requires pattern" };
|
|
params.pattern = pattern;
|
|
params.limit = clampIntegerParam(params.limit, SEARCH_FILES_LIMIT_DEFAULT, 1);
|
|
params.offset = clampIntegerParam(params.offset, 0, 0);
|
|
return { success: true, params };
|
|
}
|
|
|
|
if (tool === "search_dora_api") {
|
|
const pattern = typeof params.pattern === "string" ? params.pattern.trim() : "";
|
|
if (pattern === "") return { success: false, message: "search_dora_api requires pattern" };
|
|
params.pattern = pattern;
|
|
params.limit = clampIntegerParam(params.limit, 8, 1, SEARCH_DORA_API_LIMIT_MAX);
|
|
return { success: true, params };
|
|
}
|
|
|
|
if (tool === "glob_files") {
|
|
params.maxEntries = clampIntegerParam(params.maxEntries, LIST_FILES_MAX_ENTRIES_DEFAULT, 1);
|
|
return { success: true, params };
|
|
}
|
|
|
|
if (tool === "build") {
|
|
const path = getDecisionPath(params);
|
|
if (path !== "") {
|
|
params.path = path;
|
|
}
|
|
return { success: true, params };
|
|
}
|
|
|
|
if (tool === "list_sub_agents") {
|
|
const status = typeof params.status === "string" ? params.status.trim() : "";
|
|
if (status !== "") {
|
|
params.status = status;
|
|
}
|
|
params.limit = clampIntegerParam(params.limit, 5, 1);
|
|
params.offset = clampIntegerParam(params.offset, 0, 0);
|
|
if (typeof params.query === "string") {
|
|
params.query = params.query.trim();
|
|
}
|
|
return { success: true, params };
|
|
}
|
|
|
|
if (tool === "spawn_sub_agent") {
|
|
const prompt = typeof params.prompt === "string" ? params.prompt.trim() : "";
|
|
const title = typeof params.title === "string" ? params.title.trim() : "";
|
|
if (prompt === "") return { success: false, message: "spawn_sub_agent requires prompt" };
|
|
if (title === "") return { success: false, message: "spawn_sub_agent requires title" };
|
|
params.prompt = prompt;
|
|
params.title = title;
|
|
if (typeof params.expectedOutput === "string") {
|
|
params.expectedOutput = params.expectedOutput.trim();
|
|
}
|
|
if (isArray(params.filesHint)) {
|
|
params.filesHint = params.filesHint
|
|
.filter(item => typeof item === "string")
|
|
.map(item => sanitizeUTF8(item));
|
|
}
|
|
return { success: true, params };
|
|
}
|
|
|
|
return { success: true, params };
|
|
}
|
|
|
|
function createFunctionToolSchema(
|
|
name: AgentToolName,
|
|
description: string,
|
|
properties: Record<string, unknown>,
|
|
required: string[] = []
|
|
) {
|
|
const parameters: Record<string, unknown> = {
|
|
type: "object",
|
|
properties,
|
|
};
|
|
if (required.length > 0) {
|
|
parameters.required = required;
|
|
}
|
|
return {
|
|
type: "function" as const,
|
|
function: {
|
|
name,
|
|
description,
|
|
parameters,
|
|
},
|
|
};
|
|
}
|
|
|
|
function getAllowedToolsForRole(role: AgentRole): AgentToolName[] {
|
|
return role === "main"
|
|
? ["read_file", "edit_file", "delete_file", "grep_files", "search_dora_api", "glob_files", "build", "list_sub_agents", "spawn_sub_agent", "finish"]
|
|
: ["read_file", "edit_file", "delete_file", "grep_files", "search_dora_api", "glob_files", "build", "finish"];
|
|
}
|
|
|
|
function buildDecisionToolSchema(shared: AgentShared) {
|
|
const allowed = getAllowedToolsForRole(shared.role);
|
|
const tools = [
|
|
createFunctionToolSchema(
|
|
"read_file",
|
|
"Read a specific line range from a file. Positive line numbers are 1-based. Negative line numbers count from the end, where -1 is the last line. startLine defaults to 1. If endLine is omitted, it defaults to 300 when startLine is positive, or -1 when startLine is negative.",
|
|
{
|
|
path: { type: "string", description: "Workspace-relative file path to read." },
|
|
startLine: { type: "number", description: "Starting line number. Positive values are 1-based; negative values count from the end. Defaults to 1. 0 is invalid." },
|
|
endLine: { type: "number", description: "Ending line number. Positive values are 1-based; negative values count from the end. If omitted, defaults to 300 for positive startLine, or -1 for negative startLine. 0 is invalid." },
|
|
},
|
|
["path"]
|
|
),
|
|
createFunctionToolSchema(
|
|
"edit_file",
|
|
"Make changes to a file. Parameters: path, old_str, new_str. old_str and new_str must be different. old_str must match existing text exactly when it is non-empty. If old_str is empty, create the file when it does not exist, or clear and rewrite the whole file with new_str when it already exists.",
|
|
{
|
|
path: { type: "string", description: "Workspace-relative file path to edit." },
|
|
old_str: { type: "string", description: "Existing text to replace. If empty, edit_file rewrites the whole file, or creates it when missing." },
|
|
new_str: { type: "string", description: "Replacement text or the full file content when rewriting or creating." },
|
|
},
|
|
["path", "old_str", "new_str"]
|
|
),
|
|
createFunctionToolSchema(
|
|
"delete_file",
|
|
"Remove a file. Parameters: target_file.",
|
|
{
|
|
target_file: { type: "string", description: "Workspace-relative file path to delete." },
|
|
},
|
|
["target_file"]
|
|
),
|
|
createFunctionToolSchema(
|
|
"grep_files",
|
|
"Search text patterns inside files. Parameters: path, pattern, globs(optional), useRegex(optional), caseSensitive(optional), limit(optional), offset(optional), groupByFile(optional). path may point to either a directory or a single file. This is content search, not filename search. globs only restrict which files are searched. Search results are intentionally capped, so refine the pattern or read a specific file next.",
|
|
{
|
|
path: { type: "string", description: "Base directory or file path to search within." },
|
|
pattern: { type: "string", description: "Content pattern to search for. Use | to express OR alternatives." },
|
|
globs: { type: "array", items: { type: "string" }, description: "Optional file glob filters." },
|
|
useRegex: { type: "boolean", description: "Set true when pattern is a regular expression." },
|
|
caseSensitive: { type: "boolean", description: "Set true for case-sensitive matching." },
|
|
limit: { type: "number", description: "Maximum number of results to return." },
|
|
offset: { type: "number", description: "Offset for paginating later result pages." },
|
|
groupByFile: { type: "boolean", description: "Set true to rank candidate files before drilling into one file." },
|
|
},
|
|
["pattern"]
|
|
),
|
|
createFunctionToolSchema(
|
|
"glob_files",
|
|
"Enumerate files under a directory. Parameters: path, globs(optional), maxEntries(optional). Use this to discover files by path, extension, or glob pattern. Directory listings are intentionally capped, so narrow the path before expanding further.",
|
|
{
|
|
path: { type: "string", description: "Base directory to enumerate. Defaults to the workspace root when omitted." },
|
|
globs: { type: "array", items: { type: "string" }, description: "Optional glob filters for returned paths." },
|
|
maxEntries: { type: "number", description: "Maximum number of entries to return." },
|
|
}
|
|
),
|
|
createFunctionToolSchema(
|
|
"search_dora_api",
|
|
`Search Dora SSR game engine docs and tutorials. Parameters: pattern, docSource(api/tutorial, optional), programmingLanguage(ts/tsx/lua/yue/teal/tl/wa), limit(optional). docSource defaults to api. Use | to express OR alternatives. limit must be <= ${SEARCH_DORA_API_LIMIT_MAX}.`,
|
|
{
|
|
pattern: { type: "string", description: "Query string to search for. Use | to express OR alternatives." },
|
|
docSource: { type: "string", enum: ["api", "tutorial"], description: "Search API docs or tutorials." },
|
|
programmingLanguage: {
|
|
type: "string",
|
|
enum: ["ts", "tsx", "lua", "yue", "teal", "tl", "wa"],
|
|
description: "Preferred language variant to search.",
|
|
},
|
|
limit: { type: "number", description: `Maximum number of matches to return, up to ${SEARCH_DORA_API_LIMIT_MAX}.` },
|
|
useRegex: { type: "boolean", description: "Set true when pattern is a regular expression." },
|
|
},
|
|
["pattern"]
|
|
),
|
|
createFunctionToolSchema(
|
|
"build",
|
|
"Do compiling and static checks for ts/tsx, teal, lua, yue, yarn. Parameters: path(optional). Read the result and then decide whether another action is needed.",
|
|
{
|
|
path: { type: "string", description: "Optional workspace-relative file or directory to build." },
|
|
}
|
|
),
|
|
createFunctionToolSchema(
|
|
"list_sub_agents",
|
|
"Query current sub-agent state under the current main session, including running items and a small recent window of completed results. Use this only when you do not already know the current sub-agent state and need to decide whether to dispatch more sub agents or read a result file.",
|
|
{
|
|
status: { type: "string", enum: ["active_or_recent", "running", "done", "failed", "all"], description: "Optional status filter. Defaults to active_or_recent." },
|
|
limit: { type: "number", description: "Maximum number of items to return. Defaults to 5." },
|
|
offset: { type: "number", description: "Offset for paging older items." },
|
|
query: { type: "string", description: "Optional text filter matched against title, goal, or summary." },
|
|
}
|
|
),
|
|
createFunctionToolSchema(
|
|
"spawn_sub_agent",
|
|
"Create and start a sub agent session to execute delegated work. Use this for large multi-file work, parallel exploration, long-running verification, or isolated execution tasks; for small focused edits, use edit_file/delete_file/build directly. If dispatch succeeds, you may immediately finish the current turn without waiting for completion; the sub agent result arrives asynchronously and should be handled in a later conversation turn.",
|
|
{
|
|
title: { type: "string", description: "Short tab title for the sub agent." },
|
|
prompt: { type: "string", description: "Detailed, self-contained task prompt sent to the sub agent. Describe the concrete work to execute, constraints, expected output, and relevant files when known." },
|
|
expectedOutput: { type: "string", description: "Optional expected result summary." },
|
|
filesHint: { type: "array", items: { type: "string" }, description: "Optional likely files or directories involved." },
|
|
},
|
|
["title", "prompt"]
|
|
),
|
|
];
|
|
return tools.filter(tool => allowed.indexOf(tool.function.name as AgentToolName) >= 0);
|
|
}
|
|
|
|
function buildAgentSystemPrompt(shared: AgentShared, includeToolDefinitions = false): string {
|
|
const rolePrompt = shared.role === "main"
|
|
? shared.promptPack.mainAgentRolePrompt
|
|
: shared.promptPack.subAgentRolePrompt;
|
|
const sections: string[] = [
|
|
shared.promptPack.agentIdentityPrompt,
|
|
rolePrompt,
|
|
getReplyLanguageDirective(shared),
|
|
];
|
|
if (shared.decisionMode === "tool_calling") {
|
|
sections.push(shared.promptPack.functionCallingPrompt);
|
|
}
|
|
const memoryBudget = shared.memory.compressor.getMemoryContextBudget();
|
|
const memoryContext = shared.memory.compressor.getStorage().getRelevantMemoryContext(shared.userQuery, memoryBudget);
|
|
if (memoryContext !== "") {
|
|
sections.push(memoryContext);
|
|
}
|
|
if (includeToolDefinitions) {
|
|
sections.push("### Available Tools\n\n" + getDecisionToolDefinitions(shared));
|
|
if (shared.decisionMode === "xml") {
|
|
sections.push(buildXmlDecisionInstruction(shared));
|
|
}
|
|
}
|
|
// 添加 Skills 部分
|
|
const skillsSection = buildSkillsSection(shared);
|
|
if (skillsSection !== "") {
|
|
sections.push(skillsSection);
|
|
}
|
|
return sections.join("\n\n");
|
|
}
|
|
|
|
function buildSkillsSection(shared: AgentShared): string {
|
|
if (!shared.skills?.loader) {
|
|
return "";
|
|
}
|
|
return shared.skills.loader.buildSkillsPromptSection();
|
|
}
|
|
|
|
function sanitizeMessagesForLLMInput(messages: Message[]): Message[] {
|
|
const sanitized: Message[] = [];
|
|
let droppedAssistantToolCalls = 0;
|
|
let droppedToolResults = 0;
|
|
for (let i = 0; i < messages.length; i++) {
|
|
const message = messages[i];
|
|
if (message.role === "assistant" && message.tool_calls && message.tool_calls.length > 0) {
|
|
const requiredIds: string[] = [];
|
|
for (let j = 0; j < message.tool_calls.length; j++) {
|
|
const toolCall = message.tool_calls[j];
|
|
const id = typeof toolCall?.id === "string" ? toolCall.id : "";
|
|
if (id !== "" && requiredIds.indexOf(id) < 0) {
|
|
requiredIds.push(id);
|
|
}
|
|
}
|
|
if (requiredIds.length === 0) {
|
|
sanitized.push(message);
|
|
continue;
|
|
}
|
|
const matchedIds: Record<string, boolean> = {};
|
|
const matchedTools: Message[] = [];
|
|
let j = i + 1;
|
|
while (j < messages.length) {
|
|
const toolMessage = messages[j];
|
|
if (toolMessage.role !== "tool") break;
|
|
const toolCallId = typeof toolMessage.tool_call_id === "string" ? toolMessage.tool_call_id : "";
|
|
if (toolCallId !== "" && requiredIds.indexOf(toolCallId) >= 0 && matchedIds[toolCallId] !== true) {
|
|
matchedIds[toolCallId] = true;
|
|
matchedTools.push(toolMessage);
|
|
} else {
|
|
droppedToolResults += 1;
|
|
}
|
|
j += 1;
|
|
}
|
|
let complete = true;
|
|
for (let j = 0; j < requiredIds.length; j++) {
|
|
if (matchedIds[requiredIds[j]] !== true) {
|
|
complete = false;
|
|
break;
|
|
}
|
|
}
|
|
if (complete) {
|
|
sanitized.push(message, ...matchedTools);
|
|
} else {
|
|
droppedAssistantToolCalls += 1;
|
|
droppedToolResults += matchedTools.length;
|
|
}
|
|
i = j - 1;
|
|
continue;
|
|
}
|
|
if (message.role === "tool") {
|
|
droppedToolResults += 1;
|
|
continue;
|
|
}
|
|
sanitized.push(message);
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
function getUnconsolidatedMessages(shared: AgentShared): Message[] {
|
|
return sanitizeMessagesForLLMInput(getActiveConversationMessages(shared));
|
|
}
|
|
|
|
function getFinalDecisionTurnPrompt(shared: AgentShared): string {
|
|
return shared.useChineseResponse
|
|
? "当前已达到最大的处理轮次,请在本次处理中直接进行工作总结,不要再继续规划后续轮次。"
|
|
: "You have reached the maximum processing round. In this turn, provide a direct work summary instead of planning further rounds.";
|
|
}
|
|
|
|
function appendPromptToLatestDecisionMessage(messages: Message[], prompt: string): Message[] {
|
|
if (messages.length === 0 || prompt.trim() === "") return messages;
|
|
const next = messages.map(message => ({ ...message }));
|
|
for (let i = next.length - 1; i >= 0; i--) {
|
|
const message = next[i];
|
|
if (message.role !== "assistant" && message.role !== "user") continue;
|
|
const content = typeof message.content === "string" ? message.content.trim() : "";
|
|
message.content = content !== ""
|
|
? `${content}\n\n${prompt}`
|
|
: prompt;
|
|
return next;
|
|
}
|
|
next.push({ role: "user", content: prompt });
|
|
return next;
|
|
}
|
|
|
|
function buildDecisionMessages(
|
|
shared: AgentShared,
|
|
lastError?: string,
|
|
attempt = 1,
|
|
lastRaw?: string,
|
|
decisionMode: AgentDecisionMode = shared.decisionMode
|
|
): Message[] {
|
|
let messages: Message[] = [
|
|
{ role: "system", content: buildAgentSystemPrompt(shared, decisionMode === "xml") },
|
|
...getUnconsolidatedMessages(shared),
|
|
];
|
|
if (shared.step + 1 >= shared.maxSteps) {
|
|
messages = appendPromptToLatestDecisionMessage(messages, getFinalDecisionTurnPrompt(shared));
|
|
}
|
|
if (lastError && lastError !== "") {
|
|
const retryHeader = decisionMode === "xml"
|
|
? `Previous response was invalid (${lastError}). Return exactly one valid XML tool_call block only.`
|
|
: replacePromptVars(shared.promptPack.toolCallingRetryPrompt, { LAST_ERROR: lastError });
|
|
messages.push({
|
|
role: "user",
|
|
content: `${retryHeader}
|
|
|
|
Retry attempt: ${attempt}.
|
|
The next reply must differ from the previously rejected output.
|
|
${lastRaw && lastRaw !== "" ? `Last rejected output summary: ${truncateText(lastRaw, 300)}` : ""}`,
|
|
});
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
function buildXmlDecisionInstruction(shared: AgentShared, feedback?: string): string {
|
|
return `${shared.promptPack.xmlDecisionFormatPrompt}${feedback ?? ""}`;
|
|
}
|
|
|
|
function buildXmlRepairMessages(
|
|
shared: AgentShared,
|
|
originalRaw: string,
|
|
candidateRaw: string,
|
|
lastError: string,
|
|
attempt: number
|
|
): Message[] {
|
|
const hasCandidate = candidateRaw.trim() !== "";
|
|
const candidateSection = hasCandidate
|
|
? `### Current Candidate To Repair
|
|
\`\`\`
|
|
${truncateText(candidateRaw, 4000)}
|
|
\`\`\`
|
|
|
|
`
|
|
: "";
|
|
const repairPrompt = replacePromptVars(shared.promptPack.xmlDecisionRepairPrompt, {
|
|
TOOL_DEFINITIONS: getDecisionToolDefinitions(shared),
|
|
ORIGINAL_RAW: truncateText(originalRaw, 4000),
|
|
CANDIDATE_SECTION: candidateSection,
|
|
LAST_ERROR: lastError,
|
|
ATTEMPT: tostring(attempt),
|
|
});
|
|
return [
|
|
{
|
|
role: "system",
|
|
content: shared.promptPack.xmlDecisionSystemRepairPrompt,
|
|
},
|
|
{
|
|
role: "user",
|
|
content: repairPrompt,
|
|
},
|
|
];
|
|
}
|
|
|
|
function tryParseAndValidateDecision(rawText: string): DecisionSuccess | DecisionFailure {
|
|
const parsed = parseXMLToolCallObjectFromText(rawText);
|
|
if (!parsed.success) {
|
|
return { success: false, message: parsed.message, raw: rawText };
|
|
}
|
|
const decision = parseDecisionObject(parsed.obj);
|
|
if (!decision.success) {
|
|
return { success: false, message: decision.message, raw: rawText };
|
|
}
|
|
const validation = validateDecision(decision.tool, decision.params);
|
|
if (!validation.success) {
|
|
return { success: false, message: validation.message, raw: rawText };
|
|
}
|
|
decision.params = validation.params;
|
|
decision.toolCallId = ensureToolCallId(decision.toolCallId);
|
|
return decision;
|
|
}
|
|
|
|
function normalizeLineEndings(text: string): string {
|
|
let [res] = string.gsub(text, "\r\n", "\n");
|
|
[res] = string.gsub(res, "\r", "\n");
|
|
return res;
|
|
}
|
|
|
|
function countOccurrences(text: string, searchStr: string): number {
|
|
if (searchStr === "") return 0;
|
|
let count = 0;
|
|
let pos = 0;
|
|
while (true) {
|
|
const idx = text.indexOf(searchStr, pos);
|
|
if (idx < 0) break;
|
|
count += 1;
|
|
pos = idx + searchStr.length;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function replaceFirst(text: string, oldStr: string, newStr: string): string {
|
|
if (oldStr === "") return text;
|
|
const idx = text.indexOf(oldStr);
|
|
if (idx < 0) return text;
|
|
return text.substring(0, idx) + newStr + text.substring(idx + oldStr.length);
|
|
}
|
|
|
|
function splitLines(text: string): string[] {
|
|
return text.split("\n");
|
|
}
|
|
|
|
function getLeadingWhitespace(text: string): string {
|
|
let i = 0;
|
|
while (i < text.length) {
|
|
const ch = text[i];
|
|
if (ch !== " " && ch !== "\t") break;
|
|
i += 1;
|
|
}
|
|
return text.substring(0, i);
|
|
}
|
|
|
|
function getCommonIndentPrefix(lines: string[]): string {
|
|
let common: string | undefined;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (line.trim() === "") continue;
|
|
const indent = getLeadingWhitespace(line);
|
|
if (common === undefined) {
|
|
common = indent;
|
|
continue;
|
|
}
|
|
let j = 0;
|
|
const maxLen = math.min(common.length, indent.length);
|
|
while (j < maxLen && common[j] === indent[j]) {
|
|
j += 1;
|
|
}
|
|
common = common.substring(0, j);
|
|
if (common === "") break;
|
|
}
|
|
return common ?? "";
|
|
}
|
|
|
|
function removeIndentPrefix(line: string, indent: string): string {
|
|
if (indent !== "" && line.startsWith(indent)) {
|
|
return line.substring(indent.length);
|
|
}
|
|
const lineIndent = getLeadingWhitespace(line);
|
|
let j = 0;
|
|
const maxLen = math.min(lineIndent.length, indent.length);
|
|
while (j < maxLen && lineIndent[j] === indent[j]) {
|
|
j += 1;
|
|
}
|
|
return line.substring(j);
|
|
}
|
|
|
|
function dedentLines(lines: string[]): { indent: string; lines: string[] } {
|
|
const indent = getCommonIndentPrefix(lines);
|
|
return {
|
|
indent,
|
|
lines: lines.map(line => removeIndentPrefix(line, indent)),
|
|
};
|
|
}
|
|
|
|
function joinLines(lines: string[]): string {
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function findIndentTolerantReplacement(
|
|
content: string,
|
|
oldStr: string,
|
|
newStr: string
|
|
): { success: true; content: string } | { success: false; message: string } {
|
|
const contentLines = splitLines(content);
|
|
const oldLines = splitLines(oldStr);
|
|
if (oldLines.length === 0) {
|
|
return { success: false, message: "old_str not found in file" };
|
|
}
|
|
const dedentedOld = dedentLines(oldLines);
|
|
const dedentedOldText = joinLines(dedentedOld.lines);
|
|
const dedentedNew = dedentLines(splitLines(newStr));
|
|
const matches: { start: number; end: number; indent: string }[] = [];
|
|
for (let start = 0; start <= contentLines.length - oldLines.length; start++) {
|
|
const candidateLines = contentLines.slice(start, start + oldLines.length);
|
|
const dedentedCandidate = dedentLines(candidateLines);
|
|
if (joinLines(dedentedCandidate.lines) === dedentedOldText) {
|
|
matches.push({
|
|
start,
|
|
end: start + oldLines.length,
|
|
indent: dedentedCandidate.indent,
|
|
});
|
|
}
|
|
}
|
|
if (matches.length === 0) {
|
|
return { success: false, message: "old_str not found in file" };
|
|
}
|
|
if (matches.length > 1) {
|
|
return {
|
|
success: false,
|
|
message: `old_str appears ${matches.length} times in file after indentation normalization. Please provide more context to uniquely identify the target location.`,
|
|
};
|
|
}
|
|
const match = matches[0];
|
|
const rebuiltNewLines = dedentedNew.lines.map(line => line === "" ? "" : match.indent + line);
|
|
const nextLines = [
|
|
...contentLines.slice(0, match.start),
|
|
...rebuiltNewLines,
|
|
...contentLines.slice(match.end),
|
|
];
|
|
return { success: true, content: joinLines(nextLines) };
|
|
}
|
|
|
|
class MainDecisionAgent extends Node<AgentShared> {
|
|
async prep(shared: AgentShared): Promise<{ shared: AgentShared }> {
|
|
if (shared.stopToken.stopped || shared.step >= shared.maxSteps) {
|
|
return { shared };
|
|
}
|
|
|
|
await maybeCompressHistory(shared);
|
|
|
|
return { shared };
|
|
}
|
|
|
|
private async callDecisionByToolCalling(
|
|
shared: AgentShared,
|
|
lastError?: string,
|
|
attempt = 1,
|
|
lastRaw?: string
|
|
): Promise<DecisionResult | DecisionFailure> {
|
|
if (shared.stopToken.stopped) {
|
|
return { success: false, message: getCancelledReason(shared) };
|
|
}
|
|
Log("Info", `[CodingAgent] tool-calling decision start step=${shared.step + 1}${lastError ? ` retry_error=${lastError}` : ""}`);
|
|
const tools = buildDecisionToolSchema(shared);
|
|
const messages = buildDecisionMessages(shared, lastError, attempt, lastRaw);
|
|
const stepId = shared.step + 1;
|
|
const llmOptions = {
|
|
...shared.llmOptions,
|
|
tools,
|
|
};
|
|
emitLLMContextMetrics(shared, stepId, "decision_tool_calling", messages, llmOptions);
|
|
saveStepLLMDebugInput(shared, stepId, "decision_tool_calling", messages, llmOptions);
|
|
let lastStreamContent = "";
|
|
let lastStreamReasoning = "";
|
|
const preExecutedResults = new Map<string, PreExecutedToolResult>();
|
|
shared.preExecutedResults = preExecutedResults;
|
|
const res = await callLLMStreamAggregated(
|
|
messages,
|
|
llmOptions,
|
|
shared.stopToken,
|
|
shared.llmConfig,
|
|
(response) => {
|
|
const streamMessage = response.choices?.[0]?.message;
|
|
const nextContent = typeof streamMessage?.content === "string"
|
|
? sanitizeUTF8(streamMessage.content)
|
|
: "";
|
|
const nextReasoning = typeof streamMessage?.reasoning_content === "string"
|
|
? sanitizeUTF8(streamMessage.reasoning_content)
|
|
: "";
|
|
if (nextContent === lastStreamContent && nextReasoning === lastStreamReasoning) {
|
|
return;
|
|
}
|
|
lastStreamContent = nextContent;
|
|
lastStreamReasoning = nextReasoning;
|
|
emitAssistantMessageUpdated(shared, nextContent, nextReasoning !== "" ? nextReasoning : undefined);
|
|
},
|
|
(tc) => {
|
|
if (shared.stopToken.stopped) return;
|
|
const action = createPreExecutableActionFromStream(shared, tc);
|
|
if (!action || preExecutedResults.has(action.toolCallId)) return;
|
|
Log("Info", `[CodingAgent] streaming pre-exec tool=${action.tool} id=${action.toolCallId}`);
|
|
preExecutedResults.set(action.toolCallId, createPreExecutedToolResult(shared, action));
|
|
}
|
|
);
|
|
if (shared.stopToken.stopped) {
|
|
clearPreExecutedResults(shared);
|
|
return { success: false, message: getCancelledReason(shared) };
|
|
}
|
|
if (!res.success) {
|
|
saveStepLLMDebugOutput(shared, stepId, "decision_tool_calling", res.raw ?? res.message, { success: false });
|
|
Log("Error", `[CodingAgent] tool-calling request failed: ${res.message}`);
|
|
clearPreExecutedResults(shared);
|
|
return { success: false, message: res.message, raw: res.raw };
|
|
}
|
|
saveStepLLMDebugOutput(shared, stepId, "decision_tool_calling", encodeDebugJSON(res.response), { success: true });
|
|
const choice = res.response.choices && res.response.choices[0];
|
|
const message = choice && choice.message;
|
|
const toolCalls = message && message.tool_calls;
|
|
const reasoningContent = message && typeof message.reasoning_content === "string"
|
|
? message.reasoning_content
|
|
: undefined;
|
|
const messageContent = message && typeof message.content === "string"
|
|
? message.content.trim()
|
|
: undefined;
|
|
Log("Info", `[CodingAgent] tool-calling response finish_reason=${choice && choice.finish_reason ? choice.finish_reason : "unknown"} tool_calls=${toolCalls ? toolCalls.length : 0} content_len=${messageContent ? messageContent.length : 0} reasoning_len=${reasoningContent ? reasoningContent.length : 0}`);
|
|
if (!toolCalls || toolCalls.length === 0) {
|
|
if (messageContent && messageContent !== "") {
|
|
Log("Info", `[CodingAgent] tool-calling fallback direct_finish_len=${messageContent.length}`);
|
|
clearPreExecutedResults(shared);
|
|
return {
|
|
success: true,
|
|
tool: "finish",
|
|
params: {},
|
|
reason: messageContent,
|
|
reasoningContent,
|
|
directSummary: messageContent,
|
|
};
|
|
}
|
|
Log("Error", `[CodingAgent] missing tool call and plain-text fallback`);
|
|
clearPreExecutedResults(shared);
|
|
return {
|
|
success: false,
|
|
message: "missing tool call",
|
|
raw: reasoningContent ?? messageContent ?? "",
|
|
};
|
|
}
|
|
const decisions: DecisionSuccess[] = [];
|
|
for (let i = 0; i < toolCalls.length; i++) {
|
|
const toolCall = toolCalls[i];
|
|
const fn = toolCall && toolCall.function;
|
|
if (!fn || typeof fn.name !== "string" || fn.name === "") {
|
|
Log("Error", `[CodingAgent] missing function name for tool call index=${i + 1}`);
|
|
clearPreExecutedResults(shared);
|
|
return {
|
|
success: false,
|
|
message: `missing function name for tool call ${i + 1}`,
|
|
raw: messageContent,
|
|
};
|
|
}
|
|
const functionName = fn.name;
|
|
const argsText = typeof fn.arguments === "string" ? fn.arguments : "";
|
|
const toolCallId = toolCall && typeof toolCall.id === "string"
|
|
? toolCall.id
|
|
: undefined;
|
|
Log("Info", `[CodingAgent] tool-calling function=${functionName} index=${i + 1}/${toolCalls.length} args_len=${argsText.length}`);
|
|
const decision = parseAndValidateToolCallDecision(
|
|
shared,
|
|
functionName,
|
|
argsText,
|
|
toolCallId,
|
|
messageContent,
|
|
reasoningContent
|
|
);
|
|
if (!decision.success) {
|
|
Log("Error", `[CodingAgent] invalid tool call index=${i + 1}: ${decision.message}`);
|
|
clearPreExecutedResults(shared);
|
|
return decision;
|
|
}
|
|
decisions.push(decision);
|
|
}
|
|
if (decisions.length === 1) {
|
|
Log("Info", `[CodingAgent] tool-calling selected tool=${decisions[0].tool}`);
|
|
return decisions[0];
|
|
}
|
|
for (let i = 0; i < decisions.length; i++) {
|
|
if (decisions[i].tool === "finish") {
|
|
clearPreExecutedResults(shared);
|
|
return {
|
|
success: false,
|
|
message: "finish cannot be mixed with other tool calls",
|
|
raw: messageContent,
|
|
};
|
|
}
|
|
}
|
|
Log("Info", `[CodingAgent] tool-calling selected batch tools=${decisions.map(decision => decision.tool).join(",")}`);
|
|
return {
|
|
success: true,
|
|
kind: "batch",
|
|
decisions,
|
|
content: messageContent,
|
|
reasoningContent,
|
|
};
|
|
}
|
|
|
|
private async repairDecisionXml(
|
|
shared: AgentShared,
|
|
originalRaw: string,
|
|
initialError: string
|
|
): Promise<DecisionResult | DecisionFailure> {
|
|
Log("Info", `[CodingAgent] xml repair flow start step=${shared.step + 1} error=${initialError}`);
|
|
let lastError = initialError;
|
|
let candidateRaw = "";
|
|
for (let attempt = 0; attempt < shared.llmMaxTry; attempt++) {
|
|
Log("Info", `[CodingAgent] xml repair attempt=${attempt + 1}`);
|
|
const messages = buildXmlRepairMessages(
|
|
shared,
|
|
originalRaw,
|
|
candidateRaw,
|
|
lastError,
|
|
attempt + 1
|
|
);
|
|
const llmRes = await llm(shared, messages, "decision_xml_repair");
|
|
if (shared.stopToken.stopped) {
|
|
return { success: false, message: getCancelledReason(shared) };
|
|
}
|
|
if (!llmRes.success) {
|
|
lastError = llmRes.message;
|
|
Log("Error", `[CodingAgent] xml repair attempt failed: ${lastError}`);
|
|
continue;
|
|
}
|
|
candidateRaw = llmRes.text;
|
|
const decision = tryParseAndValidateDecision(candidateRaw);
|
|
if (decision.success) {
|
|
decision.reasoningContent = llmRes.reasoningContent;
|
|
Log("Info", `[CodingAgent] xml repair succeeded tool=${decision.tool}`);
|
|
return decision;
|
|
}
|
|
lastError = decision.message;
|
|
Log("Error", `[CodingAgent] xml repair candidate invalid: ${lastError}`);
|
|
}
|
|
Log("Error", `[CodingAgent] xml repair exhausted retries: ${lastError}`);
|
|
return {
|
|
success: false,
|
|
message: `cannot repair invalid decision xml: ${lastError}`,
|
|
raw: candidateRaw,
|
|
};
|
|
}
|
|
|
|
private async callDecisionByXml(
|
|
shared: AgentShared,
|
|
lastError?: string,
|
|
attempt = 1,
|
|
lastRaw?: string
|
|
): Promise<DecisionResult | DecisionFailure> {
|
|
const messages: Message[] = buildDecisionMessages(
|
|
shared,
|
|
lastError,
|
|
attempt,
|
|
lastRaw,
|
|
"xml"
|
|
);
|
|
const llmRes = await llm(shared, messages, "decision_xml");
|
|
if (shared.stopToken.stopped) {
|
|
return { success: false, message: getCancelledReason(shared) };
|
|
}
|
|
if (!llmRes.success) {
|
|
return {
|
|
success: false,
|
|
message: llmRes.message,
|
|
raw: llmRes.text ?? "",
|
|
};
|
|
}
|
|
const decision = tryParseAndValidateDecision(llmRes.text);
|
|
if (decision.success) {
|
|
decision.reasoningContent = llmRes.reasoningContent;
|
|
if (!isToolAllowedForRole(shared.role, decision.tool)) {
|
|
return this.repairDecisionXml(
|
|
shared,
|
|
llmRes.text,
|
|
`${decision.tool} is not allowed for role ${shared.role}`
|
|
);
|
|
}
|
|
return decision;
|
|
}
|
|
return this.repairDecisionXml(shared, llmRes.text, decision.message);
|
|
}
|
|
|
|
async exec(input: { shared: AgentShared }): Promise<DecisionResult | DecisionFailure> {
|
|
const shared = input.shared;
|
|
if (shared.stopToken.stopped) {
|
|
return { success: false, message: getCancelledReason(shared) };
|
|
}
|
|
if (shared.step >= shared.maxSteps) {
|
|
Log("Warn", `[CodingAgent] maximum step limit reached step=${shared.step} max=${shared.maxSteps}`);
|
|
return { success: false, message: getMaxStepsReachedReason(shared) };
|
|
}
|
|
|
|
if (shared.decisionMode === "tool_calling") {
|
|
Log("Info", `[CodingAgent] decision mode=tool_calling step=${shared.step + 1} messages=${getUnconsolidatedMessages(shared).length}`);
|
|
let lastError = "tool calling validation failed";
|
|
let lastRaw = "";
|
|
let shouldFallbackToXml = false;
|
|
for (let attempt = 0; attempt < shared.llmMaxTry; attempt++) {
|
|
Log("Info", `[CodingAgent] tool-calling attempt=${attempt + 1}`);
|
|
const decision = await this.callDecisionByToolCalling(
|
|
shared,
|
|
attempt > 0 ? lastError : undefined,
|
|
attempt + 1,
|
|
lastRaw
|
|
);
|
|
if (shared.stopToken.stopped) {
|
|
return { success: false, message: getCancelledReason(shared) };
|
|
}
|
|
if (decision.success) {
|
|
return decision;
|
|
}
|
|
lastError = decision.message;
|
|
lastRaw = decision.raw ?? "";
|
|
Log("Error", `[CodingAgent] tool-calling attempt failed: ${lastError}`);
|
|
if (lastError === "missing tool call") {
|
|
shouldFallbackToXml = true;
|
|
break;
|
|
}
|
|
}
|
|
if (shouldFallbackToXml) {
|
|
Log("Warn", `[CodingAgent] tool-calling returned no tool calls; falling back to XML decision format`);
|
|
lastError = "tool-calling returned no tool calls. Return exactly one valid XML tool_call block.";
|
|
for (let attempt = 0; attempt < shared.llmMaxTry; attempt++) {
|
|
Log("Info", `[CodingAgent] xml fallback attempt=${attempt + 1}`);
|
|
const decision = await this.callDecisionByXml(
|
|
shared,
|
|
attempt > 0 ? lastError : "tool-calling returned no tool calls. Use XML decision format instead.",
|
|
attempt + 1,
|
|
lastRaw
|
|
);
|
|
if (shared.stopToken.stopped) {
|
|
return { success: false, message: getCancelledReason(shared) };
|
|
}
|
|
if (decision.success) {
|
|
return decision;
|
|
}
|
|
lastError = decision.message;
|
|
lastRaw = decision.raw ?? "";
|
|
Log("Error", `[CodingAgent] xml fallback attempt failed: ${lastError}`);
|
|
}
|
|
Log("Error", `[CodingAgent] xml fallback exhausted retries: ${lastError}`);
|
|
return { success: false, message: `cannot produce valid XML decision after tool-calling fallback: ${lastError}; last_output=${truncateText(lastRaw, 400)}` };
|
|
}
|
|
Log("Error", `[CodingAgent] tool-calling exhausted retries: ${lastError}`);
|
|
return { success: false, message: `cannot produce valid tool call: ${lastError}; last_output=${truncateText(lastRaw, 400)}` };
|
|
}
|
|
|
|
let lastError = "xml validation failed";
|
|
let lastRaw = "";
|
|
for (let attempt = 0; attempt < shared.llmMaxTry; attempt++) {
|
|
const decision = await this.callDecisionByXml(
|
|
shared,
|
|
attempt > 0
|
|
? `Previous request failed before producing repairable output (${lastError}).`
|
|
: undefined,
|
|
attempt + 1,
|
|
lastRaw
|
|
);
|
|
if (shared.stopToken.stopped) {
|
|
return { success: false, message: getCancelledReason(shared) };
|
|
}
|
|
if (decision.success) {
|
|
return decision;
|
|
}
|
|
lastError = decision.message;
|
|
lastRaw = decision.raw ?? "";
|
|
}
|
|
return { success: false, message: `cannot produce valid decision xml: ${lastError}; last_output=${truncateText(lastRaw, 400)}` };
|
|
}
|
|
|
|
async post(shared: AgentShared, _prepRes: unknown, execRes: unknown): Promise<string | undefined> {
|
|
const result = execRes as DecisionResult | { success: false; message: string };
|
|
if (!result.success) {
|
|
if (shared.stopToken.stopped) {
|
|
shared.error = getCancelledReason(shared);
|
|
shared.done = true;
|
|
return "done";
|
|
}
|
|
shared.error = result.message;
|
|
shared.response = getFailureSummaryFallback(shared, result.message);
|
|
shared.done = true;
|
|
appendConversationMessage(shared, {
|
|
role: "assistant",
|
|
content: shared.response,
|
|
});
|
|
persistHistoryState(shared);
|
|
return "done";
|
|
}
|
|
if (isDecisionBatchSuccess(result)) {
|
|
const startStep = shared.step;
|
|
const actions: AgentActionRecord[] = [];
|
|
for (let i = 0; i < result.decisions.length; i++) {
|
|
const decision = result.decisions[i];
|
|
const toolCallId = ensureToolCallId(decision.toolCallId);
|
|
const step = startStep + i + 1;
|
|
const actionReason = i === 0 ? decision.reason : "";
|
|
const actionReasoningContent = i === 0 ? decision.reasoningContent : undefined;
|
|
emitAgentEvent(shared, {
|
|
type: "decision_made",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step,
|
|
tool: decision.tool,
|
|
reason: actionReason,
|
|
reasoningContent: actionReasoningContent,
|
|
params: decision.params,
|
|
});
|
|
const action: AgentActionRecord = {
|
|
step,
|
|
toolCallId,
|
|
tool: decision.tool,
|
|
reason: actionReason ?? "",
|
|
reasoningContent: actionReasoningContent,
|
|
params: decision.params,
|
|
timestamp: os.date("!%Y-%m-%dT%H:%M:%SZ"),
|
|
};
|
|
shared.history.push(action);
|
|
actions.push(action);
|
|
}
|
|
shared.step = startStep + actions.length;
|
|
shared.pendingToolActions = actions;
|
|
appendAssistantToolCallsMessage(
|
|
shared,
|
|
actions,
|
|
result.content ?? "",
|
|
result.reasoningContent
|
|
);
|
|
persistHistoryState(shared);
|
|
return "batch_tools";
|
|
}
|
|
if (result.directSummary && result.directSummary !== "") {
|
|
shared.response = result.directSummary;
|
|
shared.done = true;
|
|
appendConversationMessage(shared, {
|
|
role: "assistant",
|
|
content: result.directSummary,
|
|
reasoning_content: result.reasoningContent,
|
|
});
|
|
persistHistoryState(shared);
|
|
return "done";
|
|
}
|
|
if (result.tool === "finish") {
|
|
const finalMessage = getFinishMessage(result.params, result.reason ?? "");
|
|
shared.response = finalMessage;
|
|
shared.done = true;
|
|
appendConversationMessage(shared, {
|
|
role: "assistant",
|
|
content: finalMessage,
|
|
reasoning_content: result.reasoningContent,
|
|
});
|
|
persistHistoryState(shared);
|
|
return "done";
|
|
}
|
|
const toolCallId = ensureToolCallId(result.toolCallId);
|
|
shared.step += 1;
|
|
const step = shared.step;
|
|
emitAgentEvent(shared, {
|
|
type: "decision_made",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step,
|
|
tool: result.tool,
|
|
reason: result.reason,
|
|
reasoningContent: result.reasoningContent,
|
|
params: result.params,
|
|
});
|
|
shared.history.push({
|
|
step,
|
|
toolCallId,
|
|
tool: result.tool,
|
|
reason: result.reason ?? "",
|
|
reasoningContent: result.reasoningContent,
|
|
params: result.params,
|
|
timestamp: os.date("!%Y-%m-%dT%H:%M:%SZ"),
|
|
});
|
|
const action = shared.history[shared.history.length - 1];
|
|
appendAssistantToolCallsMessage(shared, [action], result.reason ?? "", result.reasoningContent);
|
|
if (canPreExecuteTool(action.tool)) {
|
|
shared.pendingToolActions = [action];
|
|
persistHistoryState(shared);
|
|
return "batch_tools";
|
|
}
|
|
clearPreExecutedResults(shared);
|
|
persistHistoryState(shared);
|
|
return result.tool;
|
|
}
|
|
}
|
|
|
|
class ReadFileAction extends Node<AgentShared> {
|
|
async prep(shared: AgentShared): Promise<{ path: string; startLine: number; endLine: number; tool: "read_file"; workDir: string; docLanguage: Tools.DoraAPIDocLanguage }> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (!last) throw new Error("no history");
|
|
emitAgentStartEvent(shared, last);
|
|
const path = typeof last.params.path === "string"
|
|
? last.params.path
|
|
: (typeof last.params.target_file === "string" ? last.params.target_file : "");
|
|
if (path.trim() === "") throw new Error("missing path");
|
|
return {
|
|
path,
|
|
tool: "read_file",
|
|
workDir: shared.workingDir,
|
|
docLanguage: shared.useChineseResponse ? "zh" : "en",
|
|
startLine: Number(last.params.startLine ?? 1),
|
|
endLine: Number(last.params.endLine ?? READ_FILE_DEFAULT_LIMIT),
|
|
};
|
|
}
|
|
|
|
async exec(input: { path: string; startLine: number; endLine: number; tool: "read_file"; workDir: string; docLanguage: Tools.DoraAPIDocLanguage }): Promise<Record<string, unknown>> {
|
|
return Tools.readFile(
|
|
input.workDir,
|
|
input.path,
|
|
Number(input.startLine ?? 1),
|
|
Number(input.endLine ?? READ_FILE_DEFAULT_LIMIT),
|
|
input.docLanguage
|
|
) as unknown as Record<string, unknown>;
|
|
}
|
|
|
|
async post(shared: AgentShared, _prepRes: unknown, execRes: unknown): Promise<string | undefined> {
|
|
const result = execRes as Record<string, unknown>;
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (last !== undefined) {
|
|
last.result = sanitizeReadResultForHistory(last.tool, result);
|
|
appendToolResultMessage(shared, last);
|
|
emitAgentFinishEvent(shared, last);
|
|
}
|
|
persistHistoryState(shared);
|
|
await maybeCompressHistory(shared);
|
|
persistHistoryState(shared);
|
|
return "main";
|
|
}
|
|
}
|
|
|
|
class SearchFilesAction extends Node<AgentShared> {
|
|
async prep(shared: AgentShared): Promise<{ params: Record<string, unknown>; workDir: string }> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (!last) throw new Error("no history");
|
|
emitAgentStartEvent(shared, last);
|
|
return { params: last.params, workDir: shared.workingDir };
|
|
}
|
|
|
|
async exec(input: { params: Record<string, unknown>; workDir: string }): Promise<Record<string, unknown>> {
|
|
const params = input.params;
|
|
const result = await Tools.searchFiles({
|
|
workDir: input.workDir,
|
|
path: (params.path as string) ?? "",
|
|
pattern: (params.pattern as string) ?? "",
|
|
globs: params.globs as string[] | undefined,
|
|
useRegex: params.useRegex as boolean | undefined,
|
|
caseSensitive: params.caseSensitive as boolean | undefined,
|
|
includeContent: true,
|
|
contentWindow: SEARCH_PREVIEW_CONTEXT,
|
|
limit: math.max(1, math.floor(Number(params.limit ?? SEARCH_FILES_LIMIT_DEFAULT))),
|
|
offset: math.max(0, math.floor(Number(params.offset ?? 0))),
|
|
groupByFile: params.groupByFile === true,
|
|
});
|
|
return result as unknown as Record<string, unknown>;
|
|
}
|
|
|
|
async post(shared: AgentShared, _prepRes: unknown, execRes: unknown): Promise<string | undefined> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (last !== undefined) {
|
|
const result = execRes as Record<string, unknown>;
|
|
last.result = sanitizeSearchResultForHistory(last.tool, result);
|
|
appendToolResultMessage(shared, last);
|
|
emitAgentFinishEvent(shared, last);
|
|
}
|
|
persistHistoryState(shared);
|
|
await maybeCompressHistory(shared);
|
|
persistHistoryState(shared);
|
|
return "main";
|
|
}
|
|
}
|
|
|
|
class SearchDoraAPIAction extends Node<AgentShared> {
|
|
async prep(shared: AgentShared): Promise<{ params: Record<string, unknown>; useChineseResponse: boolean }> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (!last) throw new Error("no history");
|
|
emitAgentStartEvent(shared, last);
|
|
return { params: last.params, useChineseResponse: shared.useChineseResponse };
|
|
}
|
|
|
|
async exec(input: { params: Record<string, unknown>; useChineseResponse: boolean }): Promise<Record<string, unknown>> {
|
|
const params = input.params;
|
|
const result = await Tools.searchDoraAPI({
|
|
pattern: (params.pattern as string) ?? "",
|
|
docSource: ((params.docSource as string) ?? "api") as Tools.DoraAPIDocSource,
|
|
docLanguage: (input.useChineseResponse ? "zh" : "en") as Tools.DoraAPIDocLanguage,
|
|
programmingLanguage: ((params.programmingLanguage as string) ?? "ts") as Tools.DoraAPIProgrammingLanguage,
|
|
limit: math.min(SEARCH_DORA_API_LIMIT_MAX, math.max(1, Number(params.limit ?? 8))),
|
|
useRegex: params.useRegex as boolean | undefined,
|
|
caseSensitive: false,
|
|
includeContent: true,
|
|
contentWindow: SEARCH_PREVIEW_CONTEXT,
|
|
});
|
|
return result as unknown as Record<string, unknown>;
|
|
}
|
|
|
|
async post(shared: AgentShared, _prepRes: unknown, execRes: unknown): Promise<string | undefined> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (last !== undefined) {
|
|
const result = execRes as Record<string, unknown>;
|
|
last.result = sanitizeSearchResultForHistory(last.tool, result);
|
|
appendToolResultMessage(shared, last);
|
|
emitAgentFinishEvent(shared, last);
|
|
}
|
|
persistHistoryState(shared);
|
|
await maybeCompressHistory(shared);
|
|
persistHistoryState(shared);
|
|
return "main";
|
|
}
|
|
}
|
|
|
|
class ListFilesAction extends Node<AgentShared> {
|
|
async prep(shared: AgentShared): Promise<{ params: Record<string, unknown>; workDir: string }> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (!last) throw new Error("no history");
|
|
emitAgentStartEvent(shared, last);
|
|
return { params: last.params, workDir: shared.workingDir };
|
|
}
|
|
|
|
async exec(input: { params: Record<string, unknown>; workDir: string }): Promise<Record<string, unknown>> {
|
|
const params = input.params;
|
|
const result = Tools.listFiles({
|
|
workDir: input.workDir,
|
|
path: (params.path as string) ?? "",
|
|
globs: params.globs as string[] | undefined,
|
|
maxEntries: math.max(1, math.floor(Number(params.maxEntries ?? LIST_FILES_MAX_ENTRIES_DEFAULT))),
|
|
});
|
|
return result as unknown as Record<string, unknown>;
|
|
}
|
|
|
|
async post(shared: AgentShared, _prepRes: unknown, execRes: unknown): Promise<string | undefined> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (last !== undefined) {
|
|
last.result = sanitizeListFilesResultForHistory(execRes as Record<string, unknown>);
|
|
appendToolResultMessage(shared, last);
|
|
emitAgentFinishEvent(shared, last);
|
|
}
|
|
persistHistoryState(shared);
|
|
await maybeCompressHistory(shared);
|
|
persistHistoryState(shared);
|
|
return "main";
|
|
}
|
|
}
|
|
|
|
class DeleteFileAction extends Node<AgentShared> {
|
|
async prep(shared: AgentShared): Promise<{ targetFile: string; taskId: number; workDir: string }> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (!last) throw new Error("no history");
|
|
emitAgentStartEvent(shared, last);
|
|
const targetFile = typeof last.params.target_file === "string"
|
|
? last.params.target_file
|
|
: (typeof last.params.path === "string" ? last.params.path : "");
|
|
if (targetFile.trim() === "") throw new Error("missing target_file");
|
|
return { targetFile, taskId: shared.taskId, workDir: shared.workingDir };
|
|
}
|
|
|
|
async exec(input: { targetFile: string; taskId: number; workDir: string }): Promise<Record<string, unknown>> {
|
|
const result = Tools.applyFileChanges(input.taskId, input.workDir, [{ path: input.targetFile, op: "delete" }], {
|
|
summary: `delete_file: ${input.targetFile}`,
|
|
toolName: "delete_file",
|
|
});
|
|
if (!result.success) {
|
|
return result as unknown as Record<string, unknown>;
|
|
}
|
|
return {
|
|
success: true,
|
|
changed: true,
|
|
mode: "delete",
|
|
checkpointId: result.checkpointId,
|
|
checkpointSeq: result.checkpointSeq,
|
|
files: [{ path: input.targetFile, op: "delete" as const }],
|
|
};
|
|
}
|
|
|
|
async post(shared: AgentShared, _prepRes: unknown, execRes: Record<string, unknown>): Promise<string | undefined> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (last !== undefined) {
|
|
last.params = sanitizeActionParamsForHistory(last.tool, last.params);
|
|
last.result = sanitizeToolActionResultForHistory(last, execRes);
|
|
appendToolResultMessage(shared, last);
|
|
emitAgentFinishEvent(shared, last);
|
|
const result = last.result;
|
|
if (last.tool === "delete_file"
|
|
&& typeof result.checkpointId === "number"
|
|
&& typeof result.checkpointSeq === "number"
|
|
&& isArray(result.files)) {
|
|
emitAgentEvent(shared, {
|
|
type: "checkpoint_created",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step: last.step,
|
|
tool: "delete_file",
|
|
checkpointId: result.checkpointId,
|
|
checkpointSeq: result.checkpointSeq,
|
|
files: result.files,
|
|
});
|
|
}
|
|
}
|
|
persistHistoryState(shared);
|
|
await maybeCompressHistory(shared);
|
|
persistHistoryState(shared);
|
|
return "main";
|
|
}
|
|
}
|
|
|
|
class BuildAction extends Node<AgentShared> {
|
|
async prep(shared: AgentShared): Promise<{ params: Record<string, unknown>; workDir: string }> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (!last) throw new Error("no history");
|
|
emitAgentStartEvent(shared, last);
|
|
return { params: last.params, workDir: shared.workingDir };
|
|
}
|
|
|
|
async exec(input: { params: Record<string, unknown>; workDir: string }): Promise<Record<string, unknown>> {
|
|
const params = input.params;
|
|
const result = await Tools.build({
|
|
workDir: input.workDir,
|
|
path: (params.path as string) ?? ""
|
|
});
|
|
return result as unknown as Record<string, unknown>;
|
|
}
|
|
|
|
async post(shared: AgentShared, _prepRes: unknown, execRes: unknown): Promise<string | undefined> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (last !== undefined) {
|
|
last.result = sanitizeBuildResultForHistory(execRes as Record<string, unknown>);
|
|
appendToolResultMessage(shared, last);
|
|
emitAgentFinishEvent(shared, last);
|
|
}
|
|
persistHistoryState(shared);
|
|
await maybeCompressHistory(shared);
|
|
persistHistoryState(shared);
|
|
return "main";
|
|
}
|
|
}
|
|
|
|
class SpawnSubAgentAction extends Node<AgentShared> {
|
|
async prep(shared: AgentShared): Promise<{
|
|
title: string;
|
|
prompt: string;
|
|
expectedOutput?: string;
|
|
filesHint?: string[];
|
|
sessionId?: number;
|
|
projectRoot: string;
|
|
spawnSubAgent?: CodingAgentRunOptions["spawnSubAgent"];
|
|
}> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (!last) throw new Error("no history");
|
|
emitAgentStartEvent(shared, last);
|
|
const filesHint = isArray(last.params.filesHint)
|
|
? (last.params.filesHint as unknown[]).filter(item => typeof item === "string") as string[]
|
|
: undefined;
|
|
return {
|
|
title: typeof last.params.title === "string" ? last.params.title : "Sub",
|
|
prompt: typeof last.params.prompt === "string" ? last.params.prompt : "",
|
|
expectedOutput: typeof last.params.expectedOutput === "string" ? last.params.expectedOutput : undefined,
|
|
filesHint,
|
|
sessionId: shared.sessionId,
|
|
projectRoot: shared.workingDir,
|
|
spawnSubAgent: shared.spawnSubAgent,
|
|
};
|
|
}
|
|
|
|
async exec(input: {
|
|
title: string;
|
|
prompt: string;
|
|
expectedOutput?: string;
|
|
filesHint?: string[];
|
|
sessionId?: number;
|
|
projectRoot: string;
|
|
spawnSubAgent?: CodingAgentRunOptions["spawnSubAgent"];
|
|
}): Promise<Record<string, unknown>> {
|
|
if (!input.spawnSubAgent) {
|
|
return { success: false, message: "spawn_sub_agent is not available in this runtime" };
|
|
}
|
|
if (input.sessionId === undefined || input.sessionId <= 0) {
|
|
return { success: false, message: "spawn_sub_agent requires a parent session" };
|
|
}
|
|
Log("Info", `[CodingAgent] spawn_sub_agent exec title_len=${input.title.length} prompt_len=${input.prompt.length} expected_len=${typeof input.expectedOutput === "string" ? input.expectedOutput.length : 0} files_hint_count=${input.filesHint?.length ?? 0}`);
|
|
const result = await input.spawnSubAgent({
|
|
parentSessionId: input.sessionId,
|
|
projectRoot: input.projectRoot,
|
|
title: input.title,
|
|
prompt: input.prompt,
|
|
expectedOutput: input.expectedOutput,
|
|
filesHint: input.filesHint,
|
|
});
|
|
if (!result.success) {
|
|
return result as unknown as Record<string, unknown>;
|
|
}
|
|
return {
|
|
success: true,
|
|
sessionId: result.sessionId,
|
|
taskId: result.taskId,
|
|
title: result.title,
|
|
hint: "If the necessary sub-agents have already been dispatched, end this turn directly and do not immediately check their results.",
|
|
};
|
|
}
|
|
|
|
async post(shared: AgentShared, _prepRes: unknown, execRes: unknown): Promise<string | undefined> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (last !== undefined) {
|
|
last.result = execRes as Record<string, unknown>;
|
|
appendToolResultMessage(shared, last);
|
|
emitAgentFinishEvent(shared, last);
|
|
}
|
|
persistHistoryState(shared);
|
|
await maybeCompressHistory(shared);
|
|
persistHistoryState(shared);
|
|
return "main";
|
|
}
|
|
}
|
|
|
|
class ListSubAgentsAction extends Node<AgentShared> {
|
|
async prep(shared: AgentShared): Promise<{
|
|
sessionId?: number;
|
|
projectRoot: string;
|
|
status?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
query?: string;
|
|
listSubAgents?: CodingAgentRunOptions["listSubAgents"];
|
|
}> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (!last) throw new Error("no history");
|
|
emitAgentStartEvent(shared, last);
|
|
return {
|
|
sessionId: shared.sessionId,
|
|
projectRoot: shared.workingDir,
|
|
status: typeof last.params.status === "string" ? last.params.status : undefined,
|
|
limit: typeof last.params.limit === "number" ? last.params.limit : undefined,
|
|
offset: typeof last.params.offset === "number" ? last.params.offset : undefined,
|
|
query: typeof last.params.query === "string" ? last.params.query : undefined,
|
|
listSubAgents: shared.listSubAgents,
|
|
};
|
|
}
|
|
|
|
async exec(input: {
|
|
sessionId?: number;
|
|
projectRoot: string;
|
|
status?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
query?: string;
|
|
listSubAgents?: CodingAgentRunOptions["listSubAgents"];
|
|
}): Promise<Record<string, unknown>> {
|
|
if (!input.listSubAgents) {
|
|
return { success: false, message: "list_sub_agents is not available in this runtime" };
|
|
}
|
|
if (input.sessionId === undefined || input.sessionId <= 0) {
|
|
return { success: false, message: "list_sub_agents requires a current session" };
|
|
}
|
|
const result = await input.listSubAgents({
|
|
sessionId: input.sessionId,
|
|
projectRoot: input.projectRoot,
|
|
status: input.status,
|
|
limit: input.limit,
|
|
offset: input.offset,
|
|
query: input.query,
|
|
});
|
|
return result as unknown as Record<string, unknown>;
|
|
}
|
|
|
|
async post(shared: AgentShared, _prepRes: unknown, execRes: unknown): Promise<string | undefined> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (last !== undefined) {
|
|
last.result = execRes as Record<string, unknown>;
|
|
appendToolResultMessage(shared, last);
|
|
emitAgentFinishEvent(shared, last);
|
|
}
|
|
persistHistoryState(shared);
|
|
await maybeCompressHistory(shared);
|
|
persistHistoryState(shared);
|
|
return "main";
|
|
}
|
|
}
|
|
|
|
class EditFileAction extends Node<AgentShared> {
|
|
async prep(shared: AgentShared): Promise<{ path: string; oldStr: string; newStr: string; taskId: number; workDir: string }> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (!last) throw new Error("no history");
|
|
emitAgentStartEvent(shared, last);
|
|
const path = typeof last.params.path === "string"
|
|
? last.params.path
|
|
: (typeof last.params.target_file === "string" ? last.params.target_file : "");
|
|
const oldStr = typeof last.params.old_str === "string" ? last.params.old_str : "";
|
|
const newStr = typeof last.params.new_str === "string" ? last.params.new_str : "";
|
|
if (path.trim() === "") throw new Error("missing path");
|
|
return { path, oldStr, newStr, taskId: shared.taskId, workDir: shared.workingDir };
|
|
}
|
|
|
|
async exec(input: { path: string; oldStr: string; newStr: string; taskId: number; workDir: string }): Promise<Record<string, unknown>> {
|
|
const readRes = Tools.readFileRaw(input.workDir, input.path);
|
|
if (!readRes.success) {
|
|
if (input.oldStr !== "") {
|
|
return { success: false, message: `read file failed: ${readRes.message}` };
|
|
}
|
|
const createRes = Tools.applyFileChanges(input.taskId, input.workDir, [{ path: input.path, op: "create", content: input.newStr }], {
|
|
summary: `create file ${input.path} via edit_file`,
|
|
toolName: "edit_file",
|
|
});
|
|
if (!createRes.success) {
|
|
return { success: false, message: `create file failed: ${createRes.message}` };
|
|
}
|
|
return {
|
|
success: true,
|
|
changed: true,
|
|
mode: "create",
|
|
checkpointId: createRes.checkpointId,
|
|
checkpointSeq: createRes.checkpointSeq,
|
|
files: [{ path: input.path, op: "create" as const }],
|
|
};
|
|
}
|
|
if (input.oldStr === "") {
|
|
const overwriteRes = Tools.applyFileChanges(input.taskId, input.workDir, [{ path: input.path, op: "write", content: input.newStr }], {
|
|
summary: `overwrite file ${input.path} via edit_file`,
|
|
toolName: "edit_file",
|
|
});
|
|
if (!overwriteRes.success) {
|
|
return { success: false, message: `write file failed: ${overwriteRes.message}` };
|
|
}
|
|
return {
|
|
success: true,
|
|
changed: true,
|
|
mode: "overwrite",
|
|
checkpointId: overwriteRes.checkpointId,
|
|
checkpointSeq: overwriteRes.checkpointSeq,
|
|
files: [{ path: input.path, op: "write" as const }],
|
|
};
|
|
}
|
|
|
|
// Normalize line endings for consistent matching
|
|
const normalizedContent = normalizeLineEndings(readRes.content);
|
|
const normalizedOldStr = normalizeLineEndings(input.oldStr);
|
|
const normalizedNewStr = normalizeLineEndings(input.newStr);
|
|
|
|
// Check how many times old_str appears
|
|
const occurrences = countOccurrences(normalizedContent, normalizedOldStr);
|
|
if (occurrences === 0) {
|
|
const indentTolerant = findIndentTolerantReplacement(normalizedContent, normalizedOldStr, normalizedNewStr);
|
|
if (!indentTolerant.success) {
|
|
return { success: false, message: indentTolerant.message };
|
|
}
|
|
const applyRes = Tools.applyFileChanges(input.taskId, input.workDir, [{ path: input.path, op: "write", content: indentTolerant.content }], {
|
|
summary: `replace text in ${input.path} via edit_file (indent-tolerant)`,
|
|
toolName: "edit_file",
|
|
});
|
|
if (!applyRes.success) {
|
|
return { success: false, message: `write file failed: ${applyRes.message}` };
|
|
}
|
|
return {
|
|
success: true,
|
|
changed: true,
|
|
mode: "replace_indent_tolerant",
|
|
checkpointId: applyRes.checkpointId,
|
|
checkpointSeq: applyRes.checkpointSeq,
|
|
files: [{ path: input.path, op: "write" as const }],
|
|
};
|
|
}
|
|
if (occurrences > 1) {
|
|
return { success: false, message: `old_str appears ${occurrences} times in file. Please provide more context to uniquely identify the target location.` };
|
|
}
|
|
|
|
// Perform the replacement (we know it appears exactly once)
|
|
const newContent = replaceFirst(normalizedContent, normalizedOldStr, normalizedNewStr);
|
|
const applyRes = Tools.applyFileChanges(input.taskId, input.workDir, [{ path: input.path, op: "write", content: newContent }], {
|
|
summary: `replace text in ${input.path} via edit_file`,
|
|
toolName: "edit_file",
|
|
});
|
|
if (!applyRes.success) {
|
|
return { success: false, message: `write file failed: ${applyRes.message}` };
|
|
}
|
|
return {
|
|
success: true,
|
|
changed: true,
|
|
mode: "replace",
|
|
checkpointId: applyRes.checkpointId,
|
|
checkpointSeq: applyRes.checkpointSeq,
|
|
files: [{ path: input.path, op: "write" as const }],
|
|
};
|
|
}
|
|
|
|
async post(shared: AgentShared, _prepRes: unknown, execRes: unknown): Promise<string | undefined> {
|
|
const last = shared.history[shared.history.length - 1];
|
|
if (last !== undefined) {
|
|
last.params = sanitizeActionParamsForHistory(last.tool, last.params);
|
|
last.result = sanitizeToolActionResultForHistory(last, execRes as Record<string, unknown>);
|
|
appendToolResultMessage(shared, last);
|
|
emitAgentFinishEvent(shared, last);
|
|
const result = last.result;
|
|
if ((last.tool === "edit_file" || last.tool === "delete_file")
|
|
&& typeof result.checkpointId === "number"
|
|
&& typeof result.checkpointSeq === "number"
|
|
&& isArray(result.files)) {
|
|
emitAgentEvent(shared, {
|
|
type: "checkpoint_created",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step: last.step,
|
|
tool: last.tool,
|
|
checkpointId: result.checkpointId,
|
|
checkpointSeq: result.checkpointSeq,
|
|
files: result.files,
|
|
});
|
|
}
|
|
}
|
|
persistHistoryState(shared);
|
|
await maybeCompressHistory(shared);
|
|
persistHistoryState(shared);
|
|
return "main";
|
|
}
|
|
}
|
|
|
|
function emitCheckpointEventForAction(shared: AgentShared, action: AgentActionRecord): void {
|
|
const result = action.result;
|
|
if (!result) return;
|
|
if ((action.tool === "edit_file" || action.tool === "delete_file")
|
|
&& typeof result.checkpointId === "number"
|
|
&& typeof result.checkpointSeq === "number"
|
|
&& isArray(result.files)) {
|
|
emitAgentEvent(shared, {
|
|
type: "checkpoint_created",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
step: action.step,
|
|
tool: action.tool,
|
|
checkpointId: result.checkpointId,
|
|
checkpointSeq: result.checkpointSeq,
|
|
files: result.files,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function executeToolAction(shared: AgentShared, action: AgentActionRecord): Promise<Record<string, unknown>> {
|
|
if (shared.stopToken.stopped) {
|
|
return { success: false, message: getCancelledReason(shared) };
|
|
}
|
|
const params = action.params;
|
|
if (action.tool === "read_file") {
|
|
const path = typeof params.path === "string"
|
|
? params.path
|
|
: (typeof params.target_file === "string" ? params.target_file : "");
|
|
if (path.trim() === "") return { success: false, message: "missing path" };
|
|
return Tools.readFile(
|
|
shared.workingDir,
|
|
path,
|
|
Number(params.startLine ?? 1),
|
|
Number(params.endLine ?? READ_FILE_DEFAULT_LIMIT),
|
|
shared.useChineseResponse ? "zh" : "en"
|
|
) as unknown as Record<string, unknown>;
|
|
}
|
|
if (action.tool === "grep_files") {
|
|
const result = await Tools.searchFiles({
|
|
workDir: shared.workingDir,
|
|
path: (params.path as string) ?? "",
|
|
pattern: (params.pattern as string) ?? "",
|
|
globs: params.globs as string[] | undefined,
|
|
useRegex: params.useRegex as boolean | undefined,
|
|
caseSensitive: params.caseSensitive as boolean | undefined,
|
|
includeContent: true,
|
|
contentWindow: SEARCH_PREVIEW_CONTEXT,
|
|
limit: math.max(1, math.floor(Number(params.limit ?? SEARCH_FILES_LIMIT_DEFAULT))),
|
|
offset: math.max(0, math.floor(Number(params.offset ?? 0))),
|
|
groupByFile: params.groupByFile === true,
|
|
});
|
|
return result as unknown as Record<string, unknown>;
|
|
}
|
|
if (action.tool === "search_dora_api") {
|
|
const result = await Tools.searchDoraAPI({
|
|
pattern: (params.pattern as string) ?? "",
|
|
docSource: ((params.docSource as string) ?? "api") as Tools.DoraAPIDocSource,
|
|
docLanguage: (shared.useChineseResponse ? "zh" : "en") as Tools.DoraAPIDocLanguage,
|
|
programmingLanguage: ((params.programmingLanguage as string) ?? "ts") as Tools.DoraAPIProgrammingLanguage,
|
|
limit: math.min(SEARCH_DORA_API_LIMIT_MAX, math.max(1, Number(params.limit ?? 8))),
|
|
useRegex: params.useRegex as boolean | undefined,
|
|
caseSensitive: false,
|
|
includeContent: true,
|
|
contentWindow: SEARCH_PREVIEW_CONTEXT,
|
|
});
|
|
return result as unknown as Record<string, unknown>;
|
|
}
|
|
if (action.tool === "glob_files") {
|
|
const result = Tools.listFiles({
|
|
workDir: shared.workingDir,
|
|
path: (params.path as string) ?? "",
|
|
globs: params.globs as string[] | undefined,
|
|
maxEntries: math.max(1, math.floor(Number(params.maxEntries ?? LIST_FILES_MAX_ENTRIES_DEFAULT))),
|
|
});
|
|
return result as unknown as Record<string, unknown>;
|
|
}
|
|
if (action.tool === "delete_file") {
|
|
const targetFile = typeof params.target_file === "string"
|
|
? params.target_file
|
|
: (typeof params.path === "string" ? params.path : "");
|
|
if (targetFile.trim() === "") return { success: false, message: "missing target_file" };
|
|
const result = Tools.applyFileChanges(shared.taskId, shared.workingDir, [{ path: targetFile, op: "delete" }], {
|
|
summary: `delete_file: ${targetFile}`,
|
|
toolName: "delete_file",
|
|
});
|
|
if (!result.success) {
|
|
return result as unknown as Record<string, unknown>;
|
|
}
|
|
return {
|
|
success: true,
|
|
changed: true,
|
|
mode: "delete",
|
|
checkpointId: result.checkpointId,
|
|
checkpointSeq: result.checkpointSeq,
|
|
files: [{ path: targetFile, op: "delete" as const }],
|
|
};
|
|
}
|
|
if (action.tool === "build") {
|
|
const result = await Tools.build({
|
|
workDir: shared.workingDir,
|
|
path: (params.path as string) ?? ""
|
|
});
|
|
return result as unknown as Record<string, unknown>;
|
|
}
|
|
if (action.tool === "spawn_sub_agent") {
|
|
if (!shared.spawnSubAgent) {
|
|
return { success: false, message: "spawn_sub_agent is not available in this runtime" };
|
|
}
|
|
if (shared.sessionId === undefined || shared.sessionId <= 0) {
|
|
return { success: false, message: "spawn_sub_agent requires a parent session" };
|
|
}
|
|
const filesHint = isArray(params.filesHint)
|
|
? (params.filesHint as unknown[]).filter(item => typeof item === "string") as string[]
|
|
: undefined;
|
|
const result = await shared.spawnSubAgent({
|
|
parentSessionId: shared.sessionId,
|
|
projectRoot: shared.workingDir,
|
|
title: typeof params.title === "string" ? params.title : "Sub",
|
|
prompt: typeof params.prompt === "string" ? params.prompt : "",
|
|
expectedOutput: typeof params.expectedOutput === "string" ? params.expectedOutput : undefined,
|
|
filesHint,
|
|
});
|
|
if (!result.success) {
|
|
return result as unknown as Record<string, unknown>;
|
|
}
|
|
return {
|
|
success: true,
|
|
sessionId: result.sessionId,
|
|
taskId: result.taskId,
|
|
title: result.title,
|
|
hint: "If the necessary sub-agents have already been dispatched, end this turn directly and do not immediately check their results.",
|
|
};
|
|
}
|
|
if (action.tool === "list_sub_agents") {
|
|
if (!shared.listSubAgents) {
|
|
return { success: false, message: "list_sub_agents is not available in this runtime" };
|
|
}
|
|
if (shared.sessionId === undefined || shared.sessionId <= 0) {
|
|
return { success: false, message: "list_sub_agents requires a current session" };
|
|
}
|
|
const result = await shared.listSubAgents({
|
|
sessionId: shared.sessionId,
|
|
projectRoot: shared.workingDir,
|
|
status: typeof params.status === "string" ? params.status : undefined,
|
|
limit: typeof params.limit === "number" ? params.limit : undefined,
|
|
offset: typeof params.offset === "number" ? params.offset : undefined,
|
|
query: typeof params.query === "string" ? params.query : undefined,
|
|
});
|
|
return result as unknown as Record<string, unknown>;
|
|
}
|
|
if (action.tool === "edit_file") {
|
|
const path = typeof params.path === "string"
|
|
? params.path
|
|
: (typeof params.target_file === "string" ? params.target_file : "");
|
|
const oldStr = typeof params.old_str === "string" ? params.old_str : "";
|
|
const newStr = typeof params.new_str === "string" ? params.new_str : "";
|
|
if (path.trim() === "") return { success: false, message: "missing path" };
|
|
const actionNode = new EditFileAction(1, 0);
|
|
return actionNode.exec({
|
|
path,
|
|
oldStr,
|
|
newStr,
|
|
taskId: shared.taskId,
|
|
workDir: shared.workingDir,
|
|
});
|
|
}
|
|
return { success: false, message: `${action.tool} cannot be executed as a batched tool` };
|
|
}
|
|
|
|
function sanitizeToolActionResultForHistory(action: AgentActionRecord, result: Record<string, unknown>): Record<string, unknown> {
|
|
if (action.tool === "read_file") {
|
|
return sanitizeReadResultForHistory(action.tool, result);
|
|
}
|
|
if (action.tool === "grep_files" || action.tool === "search_dora_api") {
|
|
return sanitizeSearchResultForHistory(action.tool, result);
|
|
}
|
|
if (action.tool === "glob_files") {
|
|
return sanitizeListFilesResultForHistory(result);
|
|
}
|
|
if (action.tool === "build") {
|
|
return sanitizeBuildResultForHistory(result);
|
|
}
|
|
if (action.tool === "edit_file" || action.tool === "delete_file") {
|
|
if (result.success !== true) return result;
|
|
if (typeof result.checkpointId !== "number" || typeof result.checkpointSeq !== "number") return result;
|
|
if (isArray(result.fileContext)) return result;
|
|
|
|
const contextLimits = {
|
|
fullContentChars: 12000,
|
|
previewChars: 4000,
|
|
diffChars: 8000,
|
|
totalChars: 24000,
|
|
maxFiles: 8,
|
|
};
|
|
function truncateContextSnippet(sourceText: string, maxChars: number, label: string): string {
|
|
if (maxChars <= 0) return `...${label} omitted (${sourceText.length} chars total)...`;
|
|
if (sourceText.length <= maxChars) return sourceText;
|
|
const nextUtf8Offset = utf8.offset(sourceText, maxChars + 1);
|
|
const visiblePrefix = nextUtf8Offset === undefined ? sourceText : string.sub(sourceText, 1, nextUtf8Offset - 1);
|
|
return `${visiblePrefix}\n...${label} truncated (${sourceText.length} chars total)...`;
|
|
}
|
|
function countLines(sourceText: string): number {
|
|
if (sourceText === "") return 0;
|
|
return sourceText.split("\n").length;
|
|
}
|
|
function buildUnifiedDiffPreview(filePath: string, beforeContent: string, afterContent: string, maxChars: number): string {
|
|
if (beforeContent === afterContent) return "";
|
|
const beforeLines = beforeContent.split("\n");
|
|
const afterLines = afterContent.split("\n");
|
|
const unifiedDiffLines: string[] = [`--- ${filePath}`, `+++ ${filePath}`];
|
|
let firstChangedLine = 0;
|
|
while (
|
|
firstChangedLine < beforeLines.length
|
|
&& firstChangedLine < afterLines.length
|
|
&& beforeLines[firstChangedLine] === afterLines[firstChangedLine]
|
|
) {
|
|
firstChangedLine += 1;
|
|
}
|
|
let lastChangedBeforeLine = beforeLines.length - 1;
|
|
let lastChangedAfterLine = afterLines.length - 1;
|
|
while (
|
|
lastChangedBeforeLine >= firstChangedLine
|
|
&& lastChangedAfterLine >= firstChangedLine
|
|
&& beforeLines[lastChangedBeforeLine] === afterLines[lastChangedAfterLine]
|
|
) {
|
|
lastChangedBeforeLine -= 1;
|
|
lastChangedAfterLine -= 1;
|
|
}
|
|
const previewStartLine = math.max(0, firstChangedLine - 3);
|
|
const previewEndLine = math.max(
|
|
math.min(beforeLines.length - 1, lastChangedBeforeLine + 3),
|
|
math.min(afterLines.length - 1, lastChangedAfterLine + 3)
|
|
);
|
|
unifiedDiffLines.push(`@@ ${previewStartLine + 1} @@`);
|
|
for (let lineIndex = previewStartLine; lineIndex <= previewEndLine; lineIndex++) {
|
|
const beforeLine = lineIndex < beforeLines.length ? beforeLines[lineIndex] : undefined;
|
|
const afterLine = lineIndex < afterLines.length ? afterLines[lineIndex] : undefined;
|
|
const beforeChanged = lineIndex >= firstChangedLine && lineIndex <= lastChangedBeforeLine;
|
|
const afterChanged = lineIndex >= firstChangedLine && lineIndex <= lastChangedAfterLine;
|
|
if (!beforeChanged && !afterChanged) {
|
|
const contextLine = afterLine !== undefined ? afterLine : beforeLine;
|
|
if (contextLine !== undefined) unifiedDiffLines.push(` ${contextLine}`);
|
|
continue;
|
|
}
|
|
if (beforeChanged && beforeLine !== undefined) unifiedDiffLines.push(`-${beforeLine}`);
|
|
if (afterChanged && afterLine !== undefined) unifiedDiffLines.push(`+${afterLine}`);
|
|
}
|
|
return truncateContextSnippet(unifiedDiffLines.join("\n"), maxChars, "diff");
|
|
}
|
|
|
|
const checkpointDiff = Tools.getCheckpointDiff(result.checkpointId);
|
|
if (!checkpointDiff.success) return result;
|
|
let remainingContextBudget = contextLimits.totalChars;
|
|
const fileContextItems: AgentFileContextItem[] = [];
|
|
const changedFiles = checkpointDiff.files;
|
|
const maxContextFiles = math.min(changedFiles.length, contextLimits.maxFiles);
|
|
for (let fileIndex = 0; fileIndex < maxContextFiles; fileIndex++) {
|
|
if (remainingContextBudget <= 0) break;
|
|
const changedFile = changedFiles[fileIndex];
|
|
const beforeContent = changedFile.beforeExists ? changedFile.beforeContent : "";
|
|
const afterContent = changedFile.afterExists ? changedFile.afterContent : "";
|
|
const contextItem: AgentFileContextItem = {
|
|
path: changedFile.path,
|
|
op: changedFile.op,
|
|
checkpointId: result.checkpointId,
|
|
checkpointSeq: result.checkpointSeq,
|
|
beforeExists: changedFile.beforeExists,
|
|
afterExists: changedFile.afterExists,
|
|
beforeBytes: beforeContent.length,
|
|
afterBytes: afterContent.length,
|
|
diffPreview: "",
|
|
lineCount: changedFile.afterExists ? countLines(afterContent) : 0,
|
|
contentTruncated: false,
|
|
fileListTruncated: changedFiles.length > contextLimits.maxFiles,
|
|
};
|
|
if (changedFile.afterExists) {
|
|
if (afterContent.length <= contextLimits.fullContentChars && afterContent.length <= remainingContextBudget) {
|
|
contextItem.afterContent = afterContent;
|
|
remainingContextBudget -= afterContent.length;
|
|
} else {
|
|
contextItem.afterContentPreview = truncateContextSnippet(
|
|
afterContent,
|
|
math.min(contextLimits.previewChars, math.max(400, remainingContextBudget)),
|
|
"afterContent"
|
|
);
|
|
remainingContextBudget -= contextItem.afterContentPreview.length;
|
|
contextItem.contentTruncated = true;
|
|
}
|
|
}
|
|
const diffPreview = buildUnifiedDiffPreview(
|
|
changedFile.path,
|
|
beforeContent,
|
|
afterContent,
|
|
math.min(contextLimits.diffChars, math.max(400, remainingContextBudget))
|
|
);
|
|
contextItem.diffPreview = diffPreview;
|
|
remainingContextBudget -= diffPreview.length;
|
|
if (!changedFile.afterExists && beforeContent !== "") {
|
|
contextItem.beforeContentPreview = truncateContextSnippet(
|
|
beforeContent,
|
|
math.min(contextLimits.previewChars, math.max(400, remainingContextBudget)),
|
|
"beforeContent"
|
|
);
|
|
remainingContextBudget -= contextItem.beforeContentPreview.length;
|
|
if (beforeContent.length > contextLimits.previewChars) contextItem.contentTruncated = true;
|
|
}
|
|
fileContextItems.push(contextItem);
|
|
}
|
|
if (fileContextItems.length === 0) return result;
|
|
return {
|
|
...result,
|
|
fileContext: fileContextItems,
|
|
...(changedFiles.length > maxContextFiles ? { truncatedFileContextItems: changedFiles.length - maxContextFiles } : {}),
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function canRunBatchActionInParallel(this: any, action: AgentActionRecord): boolean {
|
|
return action.tool === "read_file"
|
|
|| action.tool === "grep_files"
|
|
|| action.tool === "search_dora_api"
|
|
|| action.tool === "glob_files"
|
|
|| action.tool === "list_sub_agents";
|
|
}
|
|
|
|
interface ToolBatch {
|
|
isConcurrencySafe: boolean;
|
|
actions: AgentActionRecord[];
|
|
}
|
|
|
|
function partitionToolCalls(actions: AgentActionRecord[]): ToolBatch[] {
|
|
const batches: ToolBatch[] = [];
|
|
for (let i = 0; i < actions.length; i++) {
|
|
const action = actions[i];
|
|
const isSafe = canRunBatchActionInParallel(action);
|
|
const lastBatch = batches.length > 0 ? batches[batches.length - 1] : undefined;
|
|
if (isSafe && lastBatch && lastBatch.isConcurrencySafe) {
|
|
lastBatch.actions.push(action);
|
|
} else {
|
|
batches.push({ isConcurrencySafe: isSafe, actions: [action] });
|
|
}
|
|
}
|
|
return batches;
|
|
}
|
|
|
|
class BatchToolAction extends Node<AgentShared> {
|
|
async prep(shared: AgentShared): Promise<{ shared: AgentShared; actions: AgentActionRecord[] }> {
|
|
return { shared, actions: shared.pendingToolActions ?? [] };
|
|
}
|
|
|
|
async exec(input: { shared: AgentShared; actions: AgentActionRecord[] }): Promise<AgentActionRecord[]> {
|
|
const shared = input.shared;
|
|
const preExecuted = shared.preExecutedResults;
|
|
const batches = partitionToolCalls(input.actions);
|
|
const parallelBatchCount = batches.filter(b => b.isConcurrencySafe).length;
|
|
const serialBatchCount = batches.filter(b => !b.isConcurrencySafe).length;
|
|
Log("Info", `[CodingAgent] smart batch partition total=${input.actions.length} parallel_batches=${parallelBatchCount} serial_batches=${serialBatchCount}`);
|
|
|
|
for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
|
|
const batch = batches[batchIdx];
|
|
if (shared.stopToken.stopped) {
|
|
for (const action of batch.actions) {
|
|
if (!action.result) {
|
|
action.result = { success: false, message: getCancelledReason(shared) };
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (batch.isConcurrencySafe && batch.actions.length > 1) {
|
|
const preExecCount = batch.actions.filter(a => preExecuted?.has(a.toolCallId)).length;
|
|
Log("Info", `[CodingAgent] batch ${batchIdx + 1}/${batches.length} parallel count=${batch.actions.length} pre_executed=${preExecCount}`);
|
|
for (let i = 0; i < batch.actions.length; i++) {
|
|
emitAgentStartEvent(shared, batch.actions[i]);
|
|
}
|
|
await Promise.all(batch.actions.map(async action => {
|
|
if (shared.stopToken.stopped) {
|
|
action.result = { success: false, message: getCancelledReason(shared) };
|
|
return action;
|
|
}
|
|
const result = await executeToolActionWithPreExecution(shared, action);
|
|
action.params = sanitizeActionParamsForHistory(action.tool, action.params);
|
|
action.result = sanitizeToolActionResultForHistory(action, result);
|
|
return action;
|
|
}));
|
|
for (let i = 0; i < batch.actions.length; i++) {
|
|
const action = batch.actions[i];
|
|
if (!action.result) {
|
|
action.result = { success: false, message: "tool did not produce a result" };
|
|
}
|
|
appendToolResultMessage(shared, action);
|
|
emitAgentFinishEvent(shared, action);
|
|
emitCheckpointEventForAction(shared, action);
|
|
}
|
|
} else {
|
|
Log("Info", `[CodingAgent] batch ${batchIdx + 1}/${batches.length} serial count=${batch.actions.length}`);
|
|
for (let i = 0; i < batch.actions.length; i++) {
|
|
const action = batch.actions[i];
|
|
emitAgentStartEvent(shared, action);
|
|
const result = await executeToolActionWithPreExecution(shared, action);
|
|
action.params = sanitizeActionParamsForHistory(action.tool, action.params);
|
|
action.result = sanitizeToolActionResultForHistory(action, result);
|
|
appendToolResultMessage(shared, action);
|
|
emitAgentFinishEvent(shared, action);
|
|
emitCheckpointEventForAction(shared, action);
|
|
persistHistoryState(shared);
|
|
if (shared.stopToken.stopped) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
persistHistoryState(shared);
|
|
return input.actions;
|
|
}
|
|
|
|
async post(shared: AgentShared, _prepRes: unknown, _execRes: unknown): Promise<string | undefined> {
|
|
shared.pendingToolActions = undefined;
|
|
shared.preExecutedResults = undefined;
|
|
persistHistoryState(shared);
|
|
await maybeCompressHistory(shared);
|
|
persistHistoryState(shared);
|
|
return "main";
|
|
}
|
|
}
|
|
|
|
class EndNode extends Node<AgentShared> {
|
|
async post(_shared: AgentShared, _prepRes: unknown, _execRes: unknown): Promise<string | undefined> {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
class CodingAgentFlow extends Flow<AgentShared> {
|
|
constructor(role: AgentRole) {
|
|
const main = new MainDecisionAgent(1, 0);
|
|
const read = new ReadFileAction(1, 0);
|
|
const search = new SearchFilesAction(1, 0);
|
|
const searchDora = new SearchDoraAPIAction(1, 0);
|
|
const list = new ListFilesAction(1, 0);
|
|
const listSub = new ListSubAgentsAction(1, 0);
|
|
const del = new DeleteFileAction(1, 0);
|
|
const build = new BuildAction(1, 0);
|
|
const spawn = new SpawnSubAgentAction(1, 0);
|
|
const edit = new EditFileAction(1, 0);
|
|
const batch = new BatchToolAction(1, 0);
|
|
const done = new EndNode(1, 0);
|
|
|
|
main.on("batch_tools", batch);
|
|
main.on("grep_files", search);
|
|
main.on("search_dora_api", searchDora);
|
|
main.on("glob_files", list);
|
|
if (role === "main") {
|
|
main.on("read_file", read);
|
|
main.on("delete_file", del);
|
|
main.on("build", build);
|
|
main.on("edit_file", edit);
|
|
main.on("list_sub_agents", listSub);
|
|
main.on("spawn_sub_agent", spawn);
|
|
} else {
|
|
main.on("read_file", read);
|
|
main.on("delete_file", del);
|
|
main.on("build", build);
|
|
main.on("edit_file", edit);
|
|
}
|
|
main.on("done", done);
|
|
|
|
search.on("main", main);
|
|
searchDora.on("main", main);
|
|
list.on("main", main);
|
|
listSub.on("main", main);
|
|
spawn.on("main", main);
|
|
batch.on("main", main);
|
|
read.on("main", main);
|
|
del.on("main", main);
|
|
build.on("main", main);
|
|
edit.on("main", main);
|
|
|
|
super(main);
|
|
}
|
|
}
|
|
|
|
function emitAgentTaskFinishEvent(shared: AgentShared, success: boolean, message: string): CodingAgentRunResult {
|
|
const result = {
|
|
success,
|
|
taskId: shared.taskId,
|
|
message,
|
|
steps: shared.step
|
|
};
|
|
emitAgentEvent(shared, {
|
|
type: "task_finished",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
success: result.success,
|
|
message: result.message,
|
|
steps: result.steps,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
async function runCodingAgentAsync(options: CodingAgentRunOptions): Promise<CodingAgentRunResult> {
|
|
if (!options.workDir || !Content.isAbsolutePath(options.workDir) || !Content.exist(options.workDir) || !Content.isdir(options.workDir)) {
|
|
return { success: false, message: "workDir must be an existing absolute directory path" };
|
|
}
|
|
const normalizedPrompt = truncateAgentUserPrompt(options.prompt);
|
|
const llmConfigRes = options.llmConfig
|
|
? { success: true as const, config: options.llmConfig }
|
|
: getActiveLLMConfig();
|
|
if (!llmConfigRes.success) {
|
|
return { success: false, message: llmConfigRes.message };
|
|
}
|
|
const llmConfig = llmConfigRes.config;
|
|
const taskRes = options.taskId !== undefined
|
|
? { success: true as const, taskId: options.taskId }
|
|
: Tools.createTask(normalizedPrompt);
|
|
if (!taskRes.success) {
|
|
return { success: false, message: taskRes.message };
|
|
}
|
|
// 创建 Memory 压缩器
|
|
const compressor = new MemoryCompressor({
|
|
compressionThreshold: 0.8,
|
|
compressionTargetThreshold: 0.5,
|
|
maxCompressionRounds: 3,
|
|
projectDir: options.workDir,
|
|
llmConfig,
|
|
promptPack: options.promptPack,
|
|
scope: options.memoryScope,
|
|
});
|
|
const persistedSession = compressor.getStorage().readSessionState();
|
|
const promptPack = compressor.getPromptPack();
|
|
|
|
const shared: AgentShared = {
|
|
sessionId: options.sessionId,
|
|
taskId: taskRes.taskId,
|
|
role: options.role ?? "main",
|
|
maxSteps: math.max(1, math.floor(options.maxSteps ?? AGENT_DEFAULT_MAX_STEPS)),
|
|
llmMaxTry: math.max(1, math.floor(options.llmMaxTry ?? AGENT_DEFAULT_LLM_MAX_TRY)),
|
|
step: 0,
|
|
done: false,
|
|
stopToken: options.stopToken ?? { stopped: false },
|
|
response: "",
|
|
userQuery: normalizedPrompt,
|
|
workingDir: options.workDir,
|
|
useChineseResponse: options.useChineseResponse === true,
|
|
decisionMode: options.decisionMode
|
|
? options.decisionMode
|
|
: (llmConfig.supportsFunctionCalling ? "tool_calling" : "xml"),
|
|
llmOptions: buildLLMOptions(llmConfig, options.llmOptions),
|
|
llmConfig,
|
|
onEvent: options.onEvent,
|
|
promptPack,
|
|
history: [],
|
|
messages: persistedSession.messages,
|
|
lastConsolidatedIndex: persistedSession.lastConsolidatedIndex,
|
|
carryMessageIndex: persistedSession.carryMessageIndex,
|
|
// Memory 状态
|
|
memory: {
|
|
compressor,
|
|
},
|
|
// Skills 系统
|
|
skills: {
|
|
loader: createSkillsLoader({
|
|
projectDir: options.workDir,
|
|
}),
|
|
},
|
|
spawnSubAgent: options.spawnSubAgent,
|
|
listSubAgents: options.listSubAgents,
|
|
};
|
|
|
|
try {
|
|
emitAgentEvent(shared, {
|
|
type: "task_started",
|
|
sessionId: shared.sessionId,
|
|
taskId: shared.taskId,
|
|
prompt: shared.userQuery,
|
|
workDir: shared.workingDir,
|
|
maxSteps: shared.maxSteps,
|
|
});
|
|
if (shared.stopToken.stopped) {
|
|
Tools.setTaskStatus(shared.taskId, "STOPPED");
|
|
return emitAgentTaskFinishEvent(shared, false, getCancelledReason(shared));
|
|
}
|
|
Tools.setTaskStatus(shared.taskId, "RUNNING");
|
|
const promptCommand = getPromptCommand(shared.userQuery);
|
|
if (promptCommand === "clear") {
|
|
return clearSessionHistory(shared);
|
|
}
|
|
if (promptCommand === "compact") {
|
|
if (shared.role === "sub") {
|
|
Tools.setTaskStatus(shared.taskId, "FAILED");
|
|
return emitAgentTaskFinishEvent(
|
|
shared,
|
|
false,
|
|
shared.useChineseResponse
|
|
? "子代理会话不支持 /compact。"
|
|
: "Sub-agent sessions do not support /compact."
|
|
);
|
|
}
|
|
return await compactAllHistory(shared);
|
|
}
|
|
appendConversationMessage(shared, {
|
|
role: "user",
|
|
content: normalizedPrompt,
|
|
});
|
|
persistHistoryState(shared);
|
|
const flow = new CodingAgentFlow(shared.role);
|
|
await flow.run(shared);
|
|
if (shared.stopToken.stopped) {
|
|
Tools.setTaskStatus(shared.taskId, "STOPPED");
|
|
return emitAgentTaskFinishEvent(shared, false, getCancelledReason(shared));
|
|
}
|
|
if (shared.error) {
|
|
return finalizeAgentFailure(shared,
|
|
shared.response && shared.response !== "" ? shared.response : shared.error);
|
|
}
|
|
Tools.setTaskStatus(shared.taskId, "DONE");
|
|
return emitAgentTaskFinishEvent(shared, true,
|
|
shared.response || (shared.useChineseResponse ? "任务完成。" : "Task completed."));
|
|
} catch (e) {
|
|
return finalizeAgentFailure(shared, tostring(e));
|
|
}
|
|
}
|
|
|
|
export function runCodingAgent(options: CodingAgentRunOptions, callback: (result: CodingAgentRunResult) => void) {
|
|
runCodingAgentAsync(options).then(result => callback(result));
|
|
}
|