Files
oh-my-pi/packages/coding-agent/src/tools/learn.ts
T
can1357 3efebf8805 fix: harden merged provider, agent-loop, eager, and autolearn paths
- 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.
2026-06-14 17:09:59 +02:00

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 },
};
}
}