Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions packages/codingcode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { SessionService } from '../session/port.js';
import { SkillService } from '../skills/port.js';
import { TodoService } from '../todo/port.js';
import { ToolExecutorService } from '../tools/port.js';
import { buildSystemPrompt } from './prompt.js';
import { buildSystemPrompt, renderSkillBlock } from './prompt.js';
import type { EndTransition, FrameBody, FrameError, ResponseMeta, ToolOutcome } from '../contracts/frame.js';
import { isTurnEnd } from '../contracts/frame.js';
import type { SessionRef } from '../contracts/session.js';
Expand Down Expand Up @@ -123,9 +123,19 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () {

const toolEnv = yield* toolEnvPort.getToolEnv();

// record user (increments turn) + extract skill
const [, actualInput] = yield* skills.extractSkill(state.cwd, input);
const turnId = (yield* session.recordUser(state, actualInput)).turnId;
const turnId = (yield* session.recordUser(state, input)).turnId;

// 用户显式 @ 的 skill:按 path 回查权威数据,正文拼块后作为同回合的第二条 user 事件
if (opts.skills?.length) {
const all = yield* skills.getAll(state.cwd);
const chosen = all.filter((s) => opts.skills!.some((m) => m.path === s.skillPath));
if (chosen.length) {
const entries = yield* Effect.forEach(chosen, (s) =>
skills.readContent(s.skillPath).pipe(Effect.map((body) => ({ skill: s, body })))
);
yield* session.recordSystem(state, renderSkillBlock(entries));
}
}

// checkpoint baseline
yield* checkpoint.snapshotBaseline(state.cwd, sessionId, turnId);
Expand Down
1 change: 1 addition & 0 deletions packages/codingcode/src/agent/port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface RunTurnOptions {
parentSessionId?: string;
agentName?: string;
systemPrompt?: string;
skills?: ReadonlyArray<{ name: string; path: string }>;
}

export interface AgentShape {
Expand Down
18 changes: 18 additions & 0 deletions packages/codingcode/src/agent/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BUILD_PROMPT } from './profile.js';
import type { Skill } from '../contracts/skill.js';
import { SUBAGENT_RESULT_PREFIX } from '../contracts/session.js';

interface SystemPromptOptions {
Expand Down Expand Up @@ -39,3 +40,20 @@ export function buildSystemPrompt(opts: SystemPromptOptions): string {

return prompt;
}

export interface SkillBlockEntry {
skill: Skill;
body: string;
}

/** 用户显式 @ 的 skill 提示块;正文内嵌,path 供模型解析正文里的相对路径。 */
export function renderSkillBlock(entries: ReadonlyArray<SkillBlockEntry>): string {
if (entries.length === 0) return '';
const blocks = entries
.map(
({ skill, body }) =>
`<skill>\n<name>${skill.name}</name>\n<path>${skill.skillPath}</path>\n${body}\n</skill>`
)
.join('\n\n');
return `The user explicitly referenced the following skill(s). Follow the instructions below.\n\n${blocks}`;
}
5 changes: 3 additions & 2 deletions packages/codingcode/src/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,11 @@ const AgentWithDeps = AgentLayer.pipe(
// subagent runner (depends on agent)
const SubagentWithDeps = SubagentRunnerLayer.pipe(Layer.provide(AgentWithDeps));

// 运行注册表:要 runner 起子代理、要 mailbox 投递终态、要 sink 发 subagent_event 帧。
// 运行注册表:要 runner 起子代理、要 mailbox 投递终态、要 sink 发 subagent_event 帧、
// 要 hooks 在子代理终态时触发 agent.subagent.complete。
// 不依赖 SessionLayer —— 它不写盘,写盘由父回合循环在 drain 点做。
const SubagentRunRegistryWithDeps = SubagentRunRegistryLayer.pipe(
Layer.provide(Layer.mergeAll(SubagentWithDeps, MailboxLayer, EventSinkLayer))
Layer.provide(Layer.mergeAll(SubagentWithDeps, MailboxLayer, EventSinkLayer, HookLayer))
);

export const AppLayer = Layer.mergeAll(
Expand Down
8 changes: 7 additions & 1 deletion packages/codingcode/src/server/routes/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ export function registerMessagesRoutes(router: Hono, rt: ManagedRt): void {

router.post('/api/sessions/:id/messages', async (c) => {
let sessionId = c.req.param('id');
const { input, cwd, model } = await c.req.json<{ input: string; cwd: string; model?: string }>();
const { input, cwd, model, skills } = await c.req.json<{
input: string;
cwd: string;
model?: string;
skills?: Array<{ name: string; path: string }>;
}>();
// 模型是回合的必要输入,缺失即拒绝,不允许在 agent 层兜底成空串
if (!model?.trim()) {
return c.json(errorBody('CONFIG_MISSING', 'model is required'), 400);
Expand All @@ -28,6 +33,7 @@ export function registerMessagesRoutes(router: Hono, rt: ManagedRt): void {
cwd: normalizedCwd,
signal: c.req.raw.signal,
model,
skills,
};
if (isNew) {
// 新会话的交互/权限模式取自 config.yaml;会话一旦建立就以会话头为准
Expand Down
23 changes: 8 additions & 15 deletions packages/codingcode/src/server/routes/skills.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Hono } from 'hono';
import { dirname } from 'path';
import { Effect, ManagedRuntime } from 'effect';
import { SkillService } from '../../skills/port.js';
import { isGlobalCwd, resolveCwd } from '../cwd.js';
import { discoverGlobalSkillDirs, discoverProjectSkillDirs } from '../../skills/source.js';
import { discoverGlobalSkillDirs } from '../../skills/source.js';
import { createRunWithLayer } from '../util.js';

type ManagedRt = ManagedRuntime.ManagedRuntime<any, any>;
Expand All @@ -29,28 +30,20 @@ export function registerSkillsSettingsRoutes(router: Hono, rt: ManagedRt): void
);
}
const cwd = resolveCwd(rawCwd);
const globalDirs = discoverGlobalSkillDirs();
const projectDirs = discoverProjectSkillDirs(cwd);
const globalNames = new Set(globalDirs.map((d) => d.name));
const projectNames = new Set(projectDirs.map((d) => d.name));
const globalDirPaths = new Set(discoverGlobalSkillDirs().map((d) => d.dirPath));
const result = await runWithLayer(
Effect.gen(function* () {
const skill = yield* SkillService;
return yield* skill.getAll(cwd);
})
);
const skills = result.ok ? result.value : [];
// 按 SKILL.md 所在目录判定来源;同名同时存在于全局与项目时,两条各自标注真实来源
return c.json(
skills.map((s) => {
const isFromProject = projectNames.has(s.name);
const isFromGlobal = globalNames.has(s.name);
const hasProjectOverride = isFromProject && isFromGlobal;
return {
...s,
source: isFromProject ? 'project' : 'global',
hasProjectOverride,
};
})
skills.map((s) => ({
...s,
source: globalDirPaths.has(dirname(s.skillPath)) ? ('global' as const) : ('project' as const),
}))
);
});
}
2 changes: 1 addition & 1 deletion packages/codingcode/src/skills/port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Skill } from '../contracts/skill.js';

export interface SkillShape {
getAll(projectPath: string): Effect.Effect<Skill[]>;
extractSkill(projectPath: string, query: string): Effect.Effect<[Skill | undefined, string]>;
readContent(skillPath: string): Effect.Effect<string>;
}

export class SkillService extends Context.Tag('Skill')<SkillService, SkillShape>() {}
14 changes: 2 additions & 12 deletions packages/codingcode/src/skills/skills.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Layer, Effect } from 'effect';
import { discoverSkillDirs } from './source.js';
import { discoverSkillDirs, readSkillBody } from './source.js';
import { loadSkill } from './loader.js';
import type { Skill } from '../contracts/skill.js';
import { SkillService } from './port.js';

export const SkillLayer = Layer.effect(SkillService, Effect.gen(function* () {

Check warning on line 7 in packages/codingcode/src/skills/skills.ts

View workflow job for this annotation

GitHub Actions / lint

This generator function does not have 'yield'
const cachedByProject = new Map<string, Skill[]>();

function readAll(projectPath: string): Skill[] {
Expand All @@ -23,16 +23,6 @@
return {
getAll: (projectPath: string) => Effect.sync(() => readAll(projectPath)),

extractSkill: (projectPath: string, query: string) =>
Effect.sync(() => {
const match = query.match(/^@([a-zA-Z0-9-]+)(?:\s+|$)/);
let skill: Skill | undefined;
if (match) {
const name = match[1]!;
skill = readAll(projectPath).find((s) => s.name === name);
}
const actualQuery = query.replace(/^@[a-zA-Z0-9-]+\s*/, '');
return [skill, actualQuery] as [Skill | undefined, string];
}),
readContent: (skillPath: string) => Effect.sync(() => readSkillBody(skillPath)),
};
}));
10 changes: 7 additions & 3 deletions packages/codingcode/src/skills/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@ export interface SkillDirectory {
name: string;
}

/** 全局在前、项目在后:同名 skill 由后者覆盖(消费方按 name 去重,后写者胜)。 */
export function discoverSkillDirs(projectRoot: string): SkillDirectory[] {
return [...discoverGlobalSkillDirs(), ...discoverProjectSkillDirs(projectRoot)];
}

/** 读取 SKILL.md 全文(含 front matter),供拼 skill 提示块使用。 */
export function readSkillBody(skillPath: string): string {
return readFileSync(skillPath, 'utf8');
}

/** Parse only the SKILL.md front matter used for skill discovery. */
export function readSkillFrontMatter(dirPath: string): SkillFrontMatter | null {
const skillMdPath = join(dirPath, 'SKILL.md');
Expand All @@ -41,7 +45,7 @@ export function readSkillFrontMatter(dirPath: string): SkillFrontMatter | null {

// ---- 辅助函数:分别获取全局/项目级 Skill 目录 ----

/** `~/.codingcode/skills/` —— 先加载,同名的项目级 skill 会覆盖它 */
/** `~/.codingcode/skills/` —— 全局 skill 目录 */
export function discoverGlobalSkillDirs(): SkillDirectory[] {
const dirs: SkillDirectory[] = [];
const globalSkillsDir = join(getGlobalDir(), 'skills');
Expand All @@ -56,7 +60,7 @@ export function discoverGlobalSkillDirs(): SkillDirectory[] {
return dirs;
}

/** `<projectRoot>/.codingcode/skills/` —— 后加载,优先级更高 */
/** `<projectRoot>/.codingcode/skills/` —— 项目级 skill 目录 */
export function discoverProjectSkillDirs(projectRoot: string): SkillDirectory[] {
const dirs: SkillDirectory[] = [];
const projectSkillsDir = join(projectRoot, CODINGCODE_DIRNAME, 'skills');
Expand Down
13 changes: 11 additions & 2 deletions packages/codingcode/src/subagent/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { loadConfig } from '../infra/config.js';
import { SUBAGENT_RESULT_PREFIX } from '../contracts/session.js';
import { MailboxService } from '../session/mailbox.js';
import { EventSinkService } from '../sink/port.js';
import { HookService } from '../hooks/port.js';
import { SubagentRunnerService } from './port.js';

export type SubagentRunStatus =
Expand Down Expand Up @@ -46,6 +47,7 @@ export class SubagentRunRegistryService extends Context.Tag('SubagentRunRegistry
interface SubagentRun {
readonly sessionId: string;
readonly parentSessionId: string;
readonly parentCwd: string;
readonly agentName: string;
readonly status: SubscriptionRef.SubscriptionRef<SubagentRunStatus>;
readonly abort: AbortController;
Expand Down Expand Up @@ -116,6 +118,7 @@ export const SubagentRunRegistryLayer = Layer.scoped(
const mailbox = yield* MailboxService;
const runner = yield* SubagentRunnerService;
const sink = yield* EventSinkService;
const hooks = yield* HookService;
const runs = new Map<string, SubagentRun>(); // 键 = 子会话 sessionId;parentSessionId 只是条目上的字段

/** 帧直投父会话的出站队列:EventSink 的键就是收件人会话,不需要任何回调透传 */
Expand Down Expand Up @@ -159,6 +162,12 @@ export const SubagentRunRegistryLayer = Layer.scoped(
run.parentSessionId, run.sessionId, run.agentName,
outcome.end.reason === 'done' ? 'completed' : 'failed',
);
yield* hooks.emit('agent.subagent.complete', {
projectPath: run.parentCwd,
childSessionId: run.sessionId,
agentName: run.agentName,
status: outcome.end.reason === 'done' ? 'completed' : 'failed',
});
});

const spawn = (opts: SpawnOptions) =>
Expand All @@ -183,8 +192,8 @@ export const SubagentRunRegistryLayer = Layer.scoped(

const status = yield* SubscriptionRef.make<SubagentRunStatus>({ kind: 'running' });
const run: SubagentRun = {
sessionId, parentSessionId: opts.parentSessionId, agentName: opts.agentName,
status, abort,
sessionId, parentSessionId: opts.parentSessionId, parentCwd: opts.parentCwd,
agentName: opts.agentName, status, abort,
};
runs.set(sessionId, run);
yield* emitSubagent(opts.parentSessionId, sessionId, opts.agentName, 'spawned');
Expand Down
36 changes: 36 additions & 0 deletions packages/codingcode/test/agent/render-skill-block.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { renderSkillBlock } from '../../src/agent/prompt.js';
import type { Skill } from '../../src/contracts/skill.js';

function skill(name: string, skillPath: string): Skill {
return { name, description: `${name} description`, skillPath };
}

describe('renderSkillBlock', () => {
it('produces nothing when no skill was selected', () => {
expect(renderSkillBlock([])).toBe('');
});

it('carries the selected skill name, path and full body', () => {
const out = renderSkillBlock([
{ skill: skill('aaa-skill', '/tmp/aaa/SKILL.md'), body: 'body-of-aaa' },
]);

expect(out).toContain('aaa-skill');
expect(out).toContain('/tmp/aaa/SKILL.md');
expect(out).toContain('body-of-aaa');
});

it('renders every selected skill in the given order', () => {
const out = renderSkillBlock([
{ skill: skill('aaa-skill', '/tmp/aaa/SKILL.md'), body: 'body-of-aaa' },
{ skill: skill('bbb-skill', '/tmp/bbb/SKILL.md'), body: 'body-of-bbb' },
]);

expect(out).toContain('aaa-skill');
expect(out).toContain('body-of-aaa');
expect(out).toContain('bbb-skill');
expect(out).toContain('body-of-bbb');
expect(out.indexOf('body-of-aaa')).toBeLessThan(out.indexOf('body-of-bbb'));
});
});
Loading
Loading