新增 memory 和 skill 存储,实现 Agent 持续学习,并增加工具支持;增加 LLM progress detail 输出

This commit is contained in:
2026-05-11 16:12:20 +08:00
parent a27c45910c
commit 5fbe8ae40c
16 changed files with 1411 additions and 129 deletions
+41
View File
@@ -0,0 +1,41 @@
import { tool } from "@opencode-ai/plugin";
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({
description:
"回读由 dynamic_http_call 生成的持久化 result_ref。适用于大结果只返回 preview 时,再按需读取完整或截断后的数据。",
args: {
reason: tool.schema
.string()
.describe("Why the stored result needs to be read for the current user request."),
result_ref: tool.schema.string().describe("The result_ref returned by dynamic_http_call."),
max_items: tool.schema
.number()
.int()
.positive()
.optional()
.describe("Optional maximum number of top-level items or fields to return."),
},
async execute(args, context) {
const response = await fetch(`${internalBaseUrl}/internal/tools/fetch-result-ref`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
sessionId: context.sessionID,
result_ref: args.result_ref,
max_items: args.max_items,
}),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
+115
View File
@@ -0,0 +1,115 @@
import { tool } from "@opencode-ai/plugin";
import { MemoryStore } from "../../src/memory/store.js";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const memoryStore = new MemoryStore();
const toolContextStore = new ToolSessionContextStore();
const initializePromise = Promise.all([
memoryStore.initialize(),
toolContextStore.initialize(),
]);
const MEMORY_SIGNAL_TYPES = new Set([
"user_preference",
"user_constraint",
"project_fact",
"environment_fact",
"agent_correction",
]);
const isSignalAllowedForScope = (scope: string, signalType: string) => {
if (!MEMORY_SIGNAL_TYPES.has(signalType)) {
return false;
}
if (scope === "user") {
return signalType === "user_preference" || signalType === "user_constraint";
}
if (scope === "workspace") {
return (
signalType === "project_fact" ||
signalType === "environment_fact" ||
signalType === "agent_correction"
);
}
return false;
};
export default tool({
description:
"将高置信度、长期有效的用户偏好或项目事实写入持久 memory。禁止写入 token、password、secret、system prompt 或一次性上下文。",
args: {
reason: tool.schema
.string()
.describe("Why this memory should be persisted for future requests."),
scope: tool.schema
.string()
.describe("Target memory scope: 'user' for user preferences, 'workspace' for project/environment facts."),
signal_type: tool.schema
.string()
.describe("Signal type, e.g. user_preference, user_constraint, project_fact, environment_fact."),
confidence: tool.schema
.number()
.describe("Confidence between 0 and 1. Only high-confidence memories should be persisted."),
content: tool.schema
.string()
.describe("The durable fact or preference to remember, written as one concise sentence."),
},
async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
if (!isSignalAllowedForScope(args.scope, args.signal_type)) {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: `signal_type ${args.signal_type} is not allowed for scope ${args.scope}`,
});
}
if (args.confidence < 0.8) {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: "confidence below memory threshold",
});
}
const scope = args.scope === "user" ? "user" : args.scope === "workspace" ? "workspace" : null;
if (!scope) {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: `unsupported scope: ${args.scope}`,
});
}
const scopeKey = scope === "user" ? sessionContext.actorKey : sessionContext.projectKey;
const result = await memoryStore.upsert(scope, scopeKey, {
content: args.content,
sessionId: context.sessionID,
source: "tool",
traceId: sessionContext.traceId,
});
if (!result.entry) {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: "content rejected by persistence policy",
});
}
return JSON.stringify({
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "deduped",
detail: result.changed ? "memory stored" : "memory already existed",
target: scope,
});
},
});
+181
View File
@@ -0,0 +1,181 @@
import { tool } from "@opencode-ai/plugin";
import { join, posix } from "node:path";
import { ResultReferenceStore } from "../../src/results/store.js";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
import {
atomicWriteFile,
ensureDirectory,
readTextFile,
} from "../../src/utils/fileStore.js";
import { sanitizePersistentLine } from "../../src/utils/persistencePolicy.js";
const resultStore = new ResultReferenceStore();
const toolContextStore = new ToolSessionContextStore();
const initializePromise = Promise.all([
resultStore.initialize(),
toolContextStore.initialize(),
]);
const SKILLS_ROOT_DIR = ".opencode/skills";
const LEARNED_PATTERNS_MARKER = "## Learned Patterns";
let writeQueue: Promise<void> = Promise.resolve();
export default tool({
description:
"将已验证、可复用、非敏感的 workflow 或方法模式写入指定的 .opencode/skills 目录,由 opencode 自动识别和加载。",
args: {
reason: tool.schema
.string()
.describe("Why this workflow or method should be learned for future reuse."),
skill_path: tool.schema
.string()
.describe("Target skill directory path relative to .opencode/skills, for example analytics/simulation-analysis/leakage or platform/governance-observability/meta."),
pattern: tool.schema
.string()
.describe("A reusable workflow pattern written as one concise bullet-like sentence."),
signal_type: tool.schema
.string()
.describe("Signal type, e.g. validated_workflow, successful_complex_convergence, analysis_method, tool_recovery_pattern."),
confidence: tool.schema
.number()
.describe("Confidence between 0 and 1. Only very high-confidence patterns are stored as learned skills."),
result_refs: tool.schema
.array(tool.schema.string())
.optional()
.describe("Optional authorized result_ref list used only for evidence validation before persisting the skill."),
},
async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
const skillPath = normalizeSkillPath(args.skill_path);
if (!skillPath) {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail: "invalid skill_path; expected a relative path under .opencode/skills",
});
}
const pattern = sanitizePersistentLine(args.pattern, 320);
if (!pattern) {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail: "pattern rejected by persistence policy",
});
}
if (args.confidence < 0.85) {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail: "only very high-confidence patterns can be stored as skills",
});
}
if (args.result_refs?.length) {
await Promise.all(
args.result_refs.map(async (resultRef) => {
const record = await resultStore.peekAuthorized(resultRef, {
actorKey: sessionContext.actorKey,
projectId: sessionContext.projectId,
});
if (!record) {
throw new Error(`unauthorized or missing result_ref: ${resultRef}`);
}
}),
);
}
const result = await appendLearnedSkillPattern(skillPath, pattern);
return JSON.stringify({
ok: true,
kind: "skill",
decision: result.changed ? "accepted" : "deduped",
detail: result.changed ? "skill file updated" : "pattern already existed",
target: result.target,
});
},
});
const appendLearnedSkillPattern = async (skillPath: string, pattern: string) => {
return serializeWrite(async () => {
const target = join(SKILLS_ROOT_DIR, skillPath, "SKILL.md");
const current = (await readTextFile(target)) ?? defaultLearnedSkill(skillPath);
const existingPatterns = extractLearnedPatterns(current);
if (existingPatterns.includes(pattern)) {
return { changed: false, target };
}
const next = current.includes(LEARNED_PATTERNS_MARKER)
? current.replace(
LEARNED_PATTERNS_MARKER,
`${LEARNED_PATTERNS_MARKER}\n- ${pattern}`,
)
: `${current.trimEnd()}\n\n${LEARNED_PATTERNS_MARKER}\n- ${pattern}\n`;
await ensureDirectory(join(SKILLS_ROOT_DIR, skillPath));
await atomicWriteFile(target, next);
return { changed: true, target };
});
};
const serializeWrite = async <T>(task: () => Promise<T>) => {
const run = writeQueue.catch(() => undefined).then(task);
writeQueue = run.then(
() => undefined,
() => undefined,
);
return run;
};
const defaultLearnedSkill = (skillPath: string) => `---
name: tjwater-action-${toSkillName(skillPath)}
description: 由 skill_manager 在线追加的高置信度可复用 workflow。
version: 1.0.0
---
# learned skill
## 简介
记录由 \`skill_manager\` 在线追加的高置信度 workflow 模式。
## Learned Patterns
`;
const normalizeSkillPath = (rawSkillPath: string) => {
const normalized = posix.normalize(rawSkillPath.trim().replace(/^\/+|\/+$/g, ""));
if (!normalized || normalized === "." || normalized.startsWith("..")) {
return null;
}
if (normalized === "SKILL.md" || normalized.endsWith("/SKILL.md")) {
return null;
}
if (!/^[a-z0-9._/-]+$/i.test(normalized)) {
return null;
}
return normalized;
};
const toSkillName = (skillPath: string) =>
skillPath
.split("/")
.filter(Boolean)
.join("-")
.replace(/[^a-z0-9._-]+/gi, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 120) || "generated-skill";
const extractLearnedPatterns = (content: string) => {
if (!content.includes(LEARNED_PATTERNS_MARKER)) {
return [];
}
return (content.split(LEARNED_PATTERNS_MARKER)[1] ?? "")
.split("\n")
.filter((line) => line.trim().startsWith("- "))
.map((line) => line.trim().slice(2));
};