3efebf8805
- agent-loop: raise repetition-detection floor to 180 chars and clear thinking replay anchors when collapsing a detected loop. - providers/google: ignore empty text parts, retain terminal thoughtSignatures, and stop function-call signatures clobbering the prior block. - autolearn: capture goal-mode at the turn boundary; harden managed-skill writes against hard-links/symlinks (O_NOFOLLOW + nlink); refuse minting managed skills whose name an authored skill already claims. - eager tasks: thread agentKind through the session so a custom top-level agentId still gets always-mode delegation; split Eager Tasks prompt into hard vs soft. - title-generator: race the online title model against a local tiny-model fallback. - eager-todo: keep the soft reminder aligned with the todo init schema. - mcp/stdio: keep close() detaching the read loop instead of awaiting it. - stream loop: fix collapsing and tool-call thought-signature handling.
145 lines
5.8 KiB
TypeScript
145 lines
5.8 KiB
TypeScript
import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
import { z } from "zod/v4";
|
|
import { sanitizeSkillName, writeManagedSkill } from "../autolearn/managed-skills";
|
|
import { isNameClaimedByAuthoredSkill } from "../extensibility/skills";
|
|
import { localBackend } from "../memory-backend/local-backend";
|
|
import learnDescription from "../prompts/tools/learn.md" with { type: "text" };
|
|
import type { ToolSession } from ".";
|
|
|
|
const learnSchema = z.object({
|
|
memory: z.string().describe("the durable, self-contained lesson to remember (what, when, why)"),
|
|
context: z.string().describe("optional source context for the lesson").optional(),
|
|
skill: z
|
|
.object({
|
|
action: z.enum(["create", "update"]),
|
|
name: z.string().describe("kebab-case skill name"),
|
|
description: z.string().describe("one-line description of when to use the skill"),
|
|
body: z.string().describe("the SKILL.md body in markdown (no frontmatter)"),
|
|
})
|
|
.describe("also create or enhance a managed skill in the same call")
|
|
.optional(),
|
|
});
|
|
|
|
export type LearnParams = z.infer<typeof learnSchema>;
|
|
|
|
/**
|
|
* Orchestrating "learn" tool: persists a lesson to long-term memory and,
|
|
* given a `skill` payload, mints/enhances a managed skill via the shared
|
|
* `writeManagedSkill` primitive. Gated behind `autolearn.enabled` plus a live
|
|
* memory backend — `hindsight`/`mnemopi` (remote/SQLite) or `local` (the
|
|
* file-based rollout backend, where lessons append to `learned.md`).
|
|
*/
|
|
export class LearnTool implements AgentTool<typeof learnSchema> {
|
|
readonly name = "learn";
|
|
readonly approval = (args: unknown) =>
|
|
(args as Partial<LearnParams>).skill || this.session.settings.get("memory.backend") === "local"
|
|
? "write"
|
|
: "read";
|
|
readonly label = "Learn";
|
|
readonly description = learnDescription;
|
|
readonly parameters = learnSchema;
|
|
readonly strict = true;
|
|
readonly loadMode = "essential" as const;
|
|
readonly summary = "Capture a reusable lesson to memory (and optionally a managed skill)";
|
|
|
|
constructor(private readonly session: ToolSession) {}
|
|
|
|
static createIf(session: ToolSession): LearnTool | null {
|
|
if (!session.settings.get("autolearn.enabled")) return null;
|
|
const backend = session.settings.get("memory.backend");
|
|
if (backend !== "hindsight" && backend !== "mnemopi" && backend !== "local") return null;
|
|
return new LearnTool(session);
|
|
}
|
|
|
|
async execute(_id: string, params: LearnParams): Promise<AgentToolResult> {
|
|
// 1) Persist or queue the lesson to long-term memory (mirrors MemoryRetainTool).
|
|
const backend = this.session.settings.get("memory.backend");
|
|
let memoryMessage = "Lesson stored";
|
|
if (backend === "mnemopi") {
|
|
const state = this.session.getMnemopiSessionState?.();
|
|
if (!state) {
|
|
throw new Error("Mnemopi backend is not initialised for this session.");
|
|
}
|
|
const id = state.rememberScoped(params.memory, {
|
|
source: "coding-agent-learn",
|
|
importance: 0.8,
|
|
metadata: {
|
|
session_id: state.sessionId,
|
|
cwd: state.session.sessionManager.getCwd(),
|
|
context: params.context ?? null,
|
|
tool: "learn",
|
|
},
|
|
scope: "bank",
|
|
extract: true,
|
|
extractEntities: true,
|
|
veracity: "tool",
|
|
memoryType: "fact",
|
|
});
|
|
// rememberScoped returns undefined when the retain failed (closed DB /
|
|
// disk error); mirror mnemopiBackend.save and fail loudly rather than
|
|
// reporting (and minting a skill for) a lesson that was silently dropped.
|
|
if (!id) {
|
|
throw new Error("Mnemopi did not store the lesson (no memory id returned).");
|
|
}
|
|
} else if (backend === "local") {
|
|
const result = await localBackend.save?.(
|
|
{ agentDir: this.session.settings.getAgentDir(), cwd: this.session.settings.getCwd() },
|
|
{ content: params.memory, context: params.context, source: "coding-agent-learn", importance: 0.8 },
|
|
);
|
|
if (!result || result.stored === 0) {
|
|
throw new Error("Lesson was empty after sanitization; nothing stored.");
|
|
}
|
|
} else {
|
|
const state = this.session.getHindsightSessionState?.();
|
|
if (!state) {
|
|
throw new Error("Hindsight backend is not initialised for this session.");
|
|
}
|
|
state.enqueueRetain(params.memory, params.context);
|
|
memoryMessage = "Lesson queued for retention";
|
|
}
|
|
|
|
// 2) Optionally mint/enhance a managed skill. A failure here is surfaced
|
|
// as a partial outcome — the lesson is already stored or queued.
|
|
if (params.skill) {
|
|
// A managed skill resolves below any authored skill of the same name, so
|
|
// minting one under a claimed name writes a file that never surfaces. The
|
|
// lesson is already stored/queued; refuse the skill rather than report a
|
|
// false "Created" (mirrors ManageSkillTool).
|
|
let safeSkillName: string | undefined;
|
|
try {
|
|
safeSkillName = sanitizeSkillName(params.skill.name);
|
|
} catch {
|
|
safeSkillName = undefined;
|
|
}
|
|
if (params.skill.action === "create" && safeSkillName && isNameClaimedByAuthoredSkill(safeSkillName)) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `${memoryMessage}. Did not create managed skill "${params.skill.name}": an authored skill of that name already exists, and managed skills cannot override authored ones. Choose a different name.`,
|
|
},
|
|
],
|
|
isError: true,
|
|
details: { skill: null, shadowed: true },
|
|
};
|
|
}
|
|
try {
|
|
await writeManagedSkill(params.skill);
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`${memoryMessage}, but the managed skill could not be written: ${reason}`);
|
|
}
|
|
const verb = params.skill.action === "create" ? "Created" : "Updated";
|
|
return {
|
|
content: [{ type: "text", text: `${memoryMessage}. ${verb} managed skill "${params.skill.name}".` }],
|
|
details: { skill: params.skill.name },
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: [{ type: "text", text: `${memoryMessage}.` }],
|
|
details: { skill: null },
|
|
};
|
|
}
|
|
}
|