mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
feat: 系统提示词改为单一入口统一管理
多个扩展各自整段改写系统提示词并按加载顺序接力,任一扩展定死提示词后 其余只能做字符串手术,且各上游的工具说明与组合包实际工具格局冲突。 - 新增 extensions/system-prompt.ts 作为唯一出口(加载列表最后),按 config/prompt/(preamble.md、tools.jsonc、routing.jsonc、sections.jsonc) 组合开场白、工具简介/规则与 <tool_routing> 分区;替代原封面与 tool-routing 扩展,/dump-system-prompt 输出最终提示词。 - pi-chrome、pi-ssh、pi-hermes-memory 改为写命名分区,pi-rtk-optimizer 改写 promptGuidelines,pi-permission-system 直接过滤结构化 skills, 均不再返回 systemPrompt;pi-chrome 未授权时不再每轮冻结提示词。 - 新增 prompt-guard 代理加载第三方扩展(Hashline、FFF、LSP、 context-mode):拦截直接改写提示词并按目录覆盖工具说明;context-mode 去掉与组合包路由冲突的锚点段,保留记忆与恢复快照。 - 消除冲突:bash 简介不再建议 grep/find;Hashline edit 不再称可用 grep 锚点、不再介绍已关闭的 replace_text;context-mode 读写/网页路由提示。 - 守门测试:工具目录覆盖率、禁用用法检查、context-mode 上游原文锁定; AGENTS.md 新增「系统提示词单一所有权」。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
dc4fe92e27
commit
ba164896d2
@@ -585,7 +585,7 @@ src/
|
||||
├── permission-manager.ts Unified intent evaluation
|
||||
├── access-intent/ Resolved intent types and resolver
|
||||
├── handlers/
|
||||
│ ├── before-agent-start.ts Session warmup and skill metadata only; no active-tool control
|
||||
│ ├── before-agent-start.ts Session warmup + denied-skill filtering (structured skills array, prompt-text fallback); no active-tool control
|
||||
│ └── gates/ skill -> path -> bash -> tool pipelines
|
||||
├── authority/
|
||||
│ ├── authorizer-selection.ts Configured authorizer resolution
|
||||
|
||||
@@ -4,17 +4,30 @@ import type {
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { PermissionResolver } from "#src/permission-resolver";
|
||||
import type { PermissionSession } from "#src/permission-session";
|
||||
import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
|
||||
import {
|
||||
resolveSkillPromptEntries,
|
||||
resolveStructuredSkillEntries,
|
||||
type StructuredSkillLike,
|
||||
} from "#src/skill-prompt-sanitizer";
|
||||
|
||||
/** Minimal subset of BeforeAgentStartEvent used by this handler. */
|
||||
interface BeforeAgentStartPayload {
|
||||
systemPrompt: string;
|
||||
systemPromptOptions?: {
|
||||
skills?: StructuredSkillLike[] | unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares permission state before an agent turn without changing Pi's active
|
||||
* tool set. Tool Search is the bundle's sole active-tool controller; this
|
||||
* handler only refreshes permission state and sanitizes denied skill entries.
|
||||
*
|
||||
* Under the bundle's single system-prompt owner (extensions/system-prompt.ts)
|
||||
* no handler may return `systemPrompt`: when the event carries the structured
|
||||
* `systemPromptOptions.skills` array the denied entries are filtered out of it
|
||||
* directly. The rendered-text rewrite remains only as a fallback for hosts
|
||||
* that do not expose structured skills.
|
||||
*/
|
||||
export class AgentPrepHandler {
|
||||
constructor(
|
||||
@@ -36,6 +49,20 @@ export class AgentPrepHandler {
|
||||
this.session.refreshConfig(ctx, ctx.isProjectTrusted());
|
||||
|
||||
const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
|
||||
|
||||
const options = event.systemPromptOptions;
|
||||
if (options && Array.isArray(options.skills)) {
|
||||
const structured = resolveStructuredSkillEntries(
|
||||
options.skills,
|
||||
this.resolver,
|
||||
agentName,
|
||||
this.session.getPathNormalizer(),
|
||||
);
|
||||
options.skills = structured.skills;
|
||||
this.session.setActiveSkillEntries(structured.entries);
|
||||
return {};
|
||||
}
|
||||
|
||||
const skillPromptResult = resolveSkillPromptEntries(
|
||||
event.systemPrompt,
|
||||
this.resolver,
|
||||
|
||||
@@ -251,6 +251,82 @@ export function resolveSkillPromptEntries(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal structural shape of Pi's `Skill` as carried on
|
||||
* `systemPromptOptions.skills` (Pi 1.x). Fields not listed here are passed
|
||||
* through untouched.
|
||||
*/
|
||||
export interface StructuredSkillLike {
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
filePath?: unknown;
|
||||
baseDir?: unknown;
|
||||
disableModelInvocation?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured counterpart of `resolveSkillPromptEntries`: Pi 1.x exposes the
|
||||
* loaded skills as objects on `systemPromptOptions.skills`, so denied skills
|
||||
* are filtered out of that array instead of being cut from rendered text.
|
||||
* Skills that Pi itself hides from the prompt (`disableModelInvocation`) stay
|
||||
* in the array but are excluded from the active-entry list, matching the text
|
||||
* path where they never appeared in `<available_skills>`.
|
||||
*/
|
||||
export function resolveStructuredSkillEntries(
|
||||
skills: readonly StructuredSkillLike[],
|
||||
permissionManager: SkillPermissionChecker,
|
||||
agentName: string | null,
|
||||
normalizer: PathNormalizer,
|
||||
): { skills: StructuredSkillLike[]; entries: SkillPromptEntry[] } {
|
||||
const permissionCache = new Map<string, PermissionState>();
|
||||
const visibleSkills: StructuredSkillLike[] = [];
|
||||
const entries: SkillPromptEntry[] = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
const name = typeof skill?.name === "string" ? skill.name : "";
|
||||
if (!name) {
|
||||
visibleSkills.push(skill);
|
||||
continue;
|
||||
}
|
||||
const state = resolvePermissionState(
|
||||
name,
|
||||
permissionManager,
|
||||
agentName,
|
||||
permissionCache,
|
||||
);
|
||||
if (state === "deny") {
|
||||
continue;
|
||||
}
|
||||
visibleSkills.push(skill);
|
||||
if (skill.disableModelInvocation === true) {
|
||||
continue;
|
||||
}
|
||||
const location =
|
||||
typeof skill.filePath === "string" && skill.filePath
|
||||
? skill.filePath
|
||||
: typeof skill.baseDir === "string"
|
||||
? skill.baseDir
|
||||
: "";
|
||||
const baseDir =
|
||||
typeof skill.baseDir === "string" && skill.baseDir
|
||||
? skill.baseDir
|
||||
: location
|
||||
? dirname(location)
|
||||
: "";
|
||||
entries.push({
|
||||
name,
|
||||
description:
|
||||
typeof skill.description === "string" ? skill.description : "",
|
||||
location,
|
||||
state,
|
||||
normalizedLocation: normalizer.comparableValue(location),
|
||||
normalizedBaseDir: normalizer.comparableValue(baseDir),
|
||||
});
|
||||
}
|
||||
|
||||
return { skills: visibleSkills, entries };
|
||||
}
|
||||
|
||||
export function findSkillPathMatch(
|
||||
normalizedPath: string,
|
||||
entries: readonly SkillPromptEntry[],
|
||||
|
||||
@@ -18,6 +18,7 @@ function makeSetup() {
|
||||
return {
|
||||
handler,
|
||||
session,
|
||||
permissionManager,
|
||||
configStore,
|
||||
forwarding,
|
||||
warmParser,
|
||||
@@ -55,4 +56,35 @@ describe("AgentPrepHandler", () => {
|
||||
expect(result).toEqual({});
|
||||
expect(session.getActiveSkillEntries()).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters denied skills out of structured systemPromptOptions.skills", async () => {
|
||||
const { handler, session, permissionManager } = makeSetup();
|
||||
vi.mocked(permissionManager.check).mockImplementation((intent) =>
|
||||
makeCheckResult({
|
||||
state:
|
||||
intent.kind === "tool" &&
|
||||
(intent.input as { name?: string })?.name === "dangerous"
|
||||
? "deny"
|
||||
: "allow",
|
||||
}),
|
||||
);
|
||||
|
||||
const options = {
|
||||
skills: [
|
||||
{ name: "librarian", description: "d", filePath: "/skills/librarian/SKILL.md", baseDir: "/skills/librarian" },
|
||||
{ name: "dangerous", description: "d", filePath: "/skills/dangerous/SKILL.md", baseDir: "/skills/dangerous" },
|
||||
{ name: "manual", description: "d", filePath: "/skills/manual/SKILL.md", baseDir: "/skills/manual", disableModelInvocation: true },
|
||||
],
|
||||
};
|
||||
const result = await handler.handle(
|
||||
{ systemPrompt: "You are an assistant.", systemPromptOptions: options },
|
||||
makeCtx(),
|
||||
);
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(options.skills.map((skill) => skill.name)).toEqual(["librarian", "manual"]);
|
||||
expect(session.getActiveSkillEntries().map((entry) => entry.name)).toEqual([
|
||||
"librarian",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
findSkillPathMatch,
|
||||
parseAllSkillPromptSections,
|
||||
resolveSkillPromptEntries,
|
||||
resolveStructuredSkillEntries,
|
||||
type SkillPermissionChecker,
|
||||
} from "#src/skill-prompt-sanitizer";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
@@ -182,6 +183,92 @@ describe("resolveSkillPromptEntries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveStructuredSkillEntries ───────────────────────────────────────────
|
||||
|
||||
describe("resolveStructuredSkillEntries", () => {
|
||||
const makeSkill = (
|
||||
name: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
): Record<string, unknown> => ({
|
||||
name,
|
||||
description: `Description of ${name}`,
|
||||
filePath: `/skills/${name}/SKILL.md`,
|
||||
baseDir: `/skills/${name}`,
|
||||
disableModelInvocation: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
test("filters denied skills out of the array and keeps them out of entries", () => {
|
||||
const manager = makeManager("allow", { dangerous: "deny" });
|
||||
const skills = [makeSkill("librarian"), makeSkill("dangerous")];
|
||||
const result = resolveStructuredSkillEntries(
|
||||
skills,
|
||||
manager,
|
||||
null,
|
||||
normalizer,
|
||||
);
|
||||
expect(result.skills.map((skill) => skill.name)).toEqual(["librarian"]);
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual(["librarian"]);
|
||||
expect(result.entries[0].state).toBe("allow");
|
||||
expect(result.entries[0].location).toBe("/skills/librarian/SKILL.md");
|
||||
expect(result.entries[0].normalizedBaseDir).toBe("/skills/librarian");
|
||||
});
|
||||
|
||||
test("keeps disableModelInvocation skills in the array but out of entries", () => {
|
||||
const manager = makeManager("allow");
|
||||
const skills = [
|
||||
makeSkill("visible"),
|
||||
makeSkill("manual", { disableModelInvocation: true }),
|
||||
];
|
||||
const result = resolveStructuredSkillEntries(
|
||||
skills,
|
||||
manager,
|
||||
null,
|
||||
normalizer,
|
||||
);
|
||||
expect(result.skills.map((skill) => skill.name)).toEqual([
|
||||
"visible",
|
||||
"manual",
|
||||
]);
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual(["visible"]);
|
||||
});
|
||||
|
||||
test("passes through entries without a usable name", () => {
|
||||
const manager = makeManager("deny");
|
||||
const skills = [{ description: "no name" }];
|
||||
const result = resolveStructuredSkillEntries(
|
||||
skills,
|
||||
manager,
|
||||
null,
|
||||
normalizer,
|
||||
);
|
||||
expect(result.skills).toHaveLength(1);
|
||||
expect(manager.checkPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("caches permission results per unique skill name", () => {
|
||||
const manager = makeManager("allow");
|
||||
const skills = [makeSkill("librarian"), makeSkill("librarian")];
|
||||
resolveStructuredSkillEntries(skills, manager, null, normalizer);
|
||||
expect(manager.checkPermission).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("passes agentName to the permission check", () => {
|
||||
const manager = makeManager("allow");
|
||||
resolveStructuredSkillEntries(
|
||||
[makeSkill("librarian")],
|
||||
manager,
|
||||
"my-agent",
|
||||
normalizer,
|
||||
);
|
||||
expect(manager.checkPermission).toHaveBeenCalledWith(
|
||||
"skill",
|
||||
{ name: "librarian" },
|
||||
"my-agent",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── findSkillPathMatch ──────────────────────────────────────────────────────
|
||||
|
||||
describe("findSkillPathMatch", () => {
|
||||
|
||||
Reference in New Issue
Block a user