feat(agent): add credential refresh and unify learning tools

This commit is contained in:
2026-08-06 10:16:50 +08:00
parent 2dc37e3fd8
commit 258f4996eb
21 changed files with 1270 additions and 770 deletions
+21 -125
View File
@@ -1,11 +1,8 @@
import { tool } from "@opencode-ai/plugin";
import { MemoryStore } from "../../src/memory/store.js";
import { readBridgedRuntimeSessionContext } from "../../src/runtime/internalSessionContextBridge.js";
import { setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
const memoryStore = new MemoryStore();
const initializePromise = memoryStore.initialize();
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:
@@ -25,132 +22,31 @@ export default tool({
content: tool.schema
.string()
.optional()
.describe(
"The durable fact or preference to remember, written as one concise sentence.",
),
.describe("The durable fact or preference to remember, written as one concise sentence."),
target_id: tool.schema
.string()
.optional()
.describe("Stable memory entry id used by replace/remove."),
},
async execute(args, context) {
await initializePromise;
const sessionContext = await readBridgedRuntimeSessionContext(
context.sessionID,
);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
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}; use exact keyword 'user' or 'workspace'`,
});
}
if (sessionContext.allowLearningWrite === false && args.action !== "list") {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: "memory writes are disabled for this session",
});
}
const scopeKey =
scope === "user" ? sessionContext.actorKey : sessionContext.projectKey;
if (args.action === "list") {
const readScopes = {
...(sessionContext.memoryListReadScopes ?? {}),
[scope]: true,
};
setRuntimeSessionContext({
...sessionContext,
memoryListReadScopes: readScopes,
});
return JSON.stringify({
ok: true,
kind: "memory",
decision: "accepted",
detail: "memory listed",
items: await memoryStore.list(scope, scopeKey),
target: scope,
});
}
if (args.action === "add") {
if (sessionContext.memoryListReadScopes?.[scope] !== true) {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: `must list ${scope} memory and review existing entries before add`,
target: scope,
});
}
const result = await memoryStore.upsert(scope, scopeKey, {
content: args.content ?? "",
sessionId: sessionContext.clientSessionId,
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.detail,
entry: result.entry,
target: scope,
});
}
if (args.action === "replace") {
const result = await memoryStore.replace(
scope,
scopeKey,
args.target_id ?? "",
{
content: args.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
const response = await fetch(
`${internalBaseUrl}/internal/tools/memory-manager`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
);
return JSON.stringify({
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: scope,
});
}
const result = await memoryStore.remove(
scope,
scopeKey,
args.target_id ?? "",
body: JSON.stringify({
...args,
session_id: context.sessionID,
}),
},
);
return JSON.stringify({
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: scope,
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
+64 -157
View File
@@ -1,160 +1,67 @@
import { tool } from "@opencode-ai/plugin";
import { SkillStore } from "../../src/skills/store.js";
import {
readBridgedRuntimeSessionContext,
} from "../../src/runtime/internalSessionContextBridge.js";
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
type ToolContextReader = {
read(
sessionId: string,
): RuntimeSessionContext | null | Promise<RuntimeSessionContext | null>;
};
const runtimeContextReader: ToolContextReader = {
read: readBridgedRuntimeSessionContext,
};
export const createSkillManagerTool = (
skillStore = new SkillStore(),
toolContextStore: ToolContextReader = runtimeContextReader,
initializePromise: Promise<unknown> = Promise.resolve(),
) =>
tool({
description:
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
args: {
action: tool.schema
.enum([
"list",
"write_skill",
"remove_skill",
"append_pattern",
"remove_pattern",
"write_reference",
"remove_reference",
"write_script",
"remove_script",
])
.describe("Skill maintenance operation."),
reason: tool.schema
.string()
.describe(
"Why this skill maintenance action is justified for future reuse.",
),
skill_path: tool.schema
.string()
.describe(
"Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.",
),
pattern: tool.schema
.string()
.optional()
.describe("Pattern text used by append_pattern."),
target_id: tool.schema
.string()
.optional()
.describe("Stable learned pattern id used by remove_pattern."),
file_path: tool.schema
.string()
.optional()
.describe(
"Asset file path. For references use references/*.md; for scripts use scripts/*.py.",
),
content: tool.schema
.string()
.optional()
.describe(
"Content used by write_skill, write_reference, or write_script.",
),
},
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 (
sessionContext.allowLearningWrite === false &&
args.action !== "list"
) {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail: "skill writes are disabled for this session",
});
}
if (args.action === "list") {
const result = await skillStore.list(args.skill_path);
if (!result) {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail:
"invalid skill_path; expected a relative path under .opencode/skills",
});
}
return JSON.stringify({
ok: true,
kind: "skill",
decision: "accepted",
detail: "skill listed",
references: result.references,
scripts: result.scripts,
skill_path: result.skillPath,
target: result.target,
patterns: result.patterns,
});
}
const result =
args.action === "write_skill"
? await skillStore.writeSkill(args.skill_path, args.content ?? "")
: args.action === "remove_skill"
? await skillStore.removeSkill(args.skill_path)
: args.action === "append_pattern"
? await skillStore.appendPattern(
args.skill_path,
args.pattern ?? "",
)
: args.action === "remove_pattern"
? await skillStore.removePattern(
args.skill_path,
args.target_id ?? "",
)
: args.action === "write_reference"
? await skillStore.writeReference(
args.skill_path,
args.file_path ?? "",
args.content ?? "",
)
: args.action === "remove_reference"
? await skillStore.removeReference(
args.skill_path,
args.file_path ?? "",
)
: args.action === "write_script"
? await skillStore.writeScript(
args.skill_path,
args.file_path ?? "",
args.content ?? "",
)
: await skillStore.removeScript(
args.skill_path,
args.file_path ?? "",
);
return JSON.stringify({
ok: true,
kind: "skill",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: result.target,
});
},
});
export default createSkillManagerTool();
export default tool({
description:
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
args: {
action: tool.schema
.enum([
"list",
"write_skill",
"remove_skill",
"append_pattern",
"remove_pattern",
"write_reference",
"remove_reference",
"write_script",
"remove_script",
])
.describe("Skill maintenance operation."),
reason: tool.schema
.string()
.describe("Why this skill maintenance action is justified for future reuse."),
skill_path: tool.schema
.string()
.describe(
"Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.",
),
pattern: tool.schema.string().optional().describe("Pattern text used by append_pattern."),
target_id: tool.schema
.string()
.optional()
.describe("Stable learned pattern id used by remove_pattern."),
file_path: tool.schema
.string()
.optional()
.describe("Asset file path. For references use references/*.md; for scripts use scripts/*.py."),
content: tool.schema
.string()
.optional()
.describe("Content used by write_skill, write_reference, or write_script."),
},
async execute(args, context) {
const response = await fetch(
`${internalBaseUrl}/internal/tools/skill-manager`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
...args,
session_id: context.sessionID,
}),
},
);
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});