diff --git a/docs/architecture.md b/docs/architecture.md index 216ed949b6..d062b4b104 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,10 +24,12 @@ For a catalog of the **data structures** this architecture moves around — the │ @deepseek-ai/dsh-agent-loop (the ONE concrete plugin) │ │ @deepseek-ai/dsh-bash-local (bash impl) │ │ @deepseek-ai/dsh-tool-bash (bash tool schemas) │ +│ @deepseek-ai/dsh-tool-skill (skill loader tool) │ │ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│ ├─────────────────────────────────────────────────────────────┤ │ @deepseek-ai/dsh-agent (vocabulary + registry) │ │ @deepseek-ai/dsh-tools (registry + exec waterfall)│ +│ @deepseek-ai/dsh-skill (skill discovery + listing)│ │ @deepseek-ai/dsh-system-prompt (assembly registry) │ │ @deepseek-ai/dsh-session (event-sourced log) │ │ @deepseek-ai/dsh-session-persistence (persistence seam) │ @@ -50,6 +52,7 @@ Dependency rule: **extension** plugins depend on interface packages, never on `d | `ctx.sessionPersistence` | `SessionPersistence` (abstract) | dsh-session-persistence | durable persistence seam: create/append/load/list sessions | | `ctx.systemPrompt` | `SystemPrompt` | dsh-system-prompt | ordered sections + tool schemas → `assemble()` | | `ctx.tools` | `ToolRegistry` | dsh-tools | tool definitions; `execute()` through waterfall | +| `ctx.skills` | `SkillService` | dsh-skill | discovers user/project/system skills and adds request-time skill guidance | | `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam (returns an `AgentHandle` = `{ agent, dispose() }` for owned per-agent teardown) | | `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops | | `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks | @@ -202,7 +205,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl | Plan mode | wrap `tools/execute` (deny writes) + `agent/request` (inject mode prompt) | | Sub-agents (spawn / fork / steer) | TODO seam on `AgentLoop.create()`; fork = seed Session with parent events; `steer()` on the child handle | | MCP | one plugin per server: discover tools → `ctx.tools.register()` | -| Skills | section + tool registration; `inject()` skill content on invocation | +| Skills | `dsh-skill` discovers `~/.dsh/skills`, `~/.agents/skills`, project `.dsh/skills`/`.agents/skills`, and system `~/.dsh/skills/.system`; `agent/request` appends the model-visible listing; `dsh-tool-skill` loads full skill content on demand | | Memory | section provider + tool | | Scheduled tasks (cron) | plugin registers model-callable scheduling tools; timer fires → `send(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy | | UI (GUI; CLI emits JSONL) | listen `agent/stream-chunk` + `session/event`; input → `send()` | diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index f424b8af09..84360b6140 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -301,7 +301,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in ## Services -The 9 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +The 10 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. ### `ctx.agentLoop` — `AgentLoop` @@ -310,12 +310,12 @@ The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loo The loop itself is deliberately thin — every behavior beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy declared in @deepseek-ai/dsh-agent. ```ts cordis-catalog -create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent +create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent createAgent(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:63`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:65`](../../packages/core/agent-loop/src/index.ts) ### `ctx.agents` — `AgentRegistry` @@ -414,6 +414,17 @@ list(): Session[] Source: [`packages/core/session/src/index.ts:229`](../../packages/core/session/src/index.ts) +### `ctx.skills` — `SkillService` + +```ts cordis-catalog +register(skill: SkillRegistration): () => void +async list(options: SkillLookupOptions = {}): Promise +async get(name: string, options: SkillLookupOptions = {}): Promise +async renderModelListing(options: SkillLookupOptions = {}): Promise +``` + +Source: [`packages/core/skill/src/index.ts:106`](../../packages/core/skill/src/index.ts) + ### `ctx.subagents` — `SubagentService` The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface. diff --git a/docs/module-graph.md b/docs/module-graph.md index 93ad58725d..3dfe4b8a08 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -28,6 +28,8 @@ graph TD session-persistence-jsonl --> session-persistence session-persistence-sqlite --> session session-persistence-sqlite --> session-persistence + skill --> agent + skill --> llm tools --> agent tools --> llm tools --> system-prompt @@ -52,13 +54,19 @@ graph TD tool-bash --> bash tool-bash --> llm tool-bash --> tools + tool-skill --> agent + tool-skill --> llm + tool-skill --> skill + tool-skill --> tools agent-core --> agent agent-core --> agent-loop agent-core --> invariants agent-core --> llm agent-core --> session + agent-core --> skill agent-core --> system-prompt agent-core --> tool-bash + agent-core --> tool-skill agent-core --> tools subagent-acp --> agent subagent-acp --> llm @@ -106,13 +114,15 @@ graph TD | `invariants` | `agent`, `llm`, `session` | | `session-persistence-jsonl` | `session`, `session-persistence` | | `session-persistence-sqlite` | `session`, `session-persistence` | +| `skill` | `agent`, `llm` | | `tools` | `agent`, `llm`, `system-prompt` | | `ui-stdio` | `agent`, `llm`, `session` | | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | | `subagent` | `agent`, `llm`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | -| `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | +| `tool-skill` | `agent`, `llm`, `skill`, `tools` | +| `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `skill`, `system-prompt`, `tool-bash`, `tool-skill`, `tools` | | `subagent-acp` | `agent`, `llm`, `subagent` | | `subagent-inprocess` | `agent`, `llm`, `session`, `subagent` | | `subagent-mock` | `agent`, `llm`, `subagent` | diff --git a/packages/README.md b/packages/README.md index c24e95838f..a76f7d5cfa 100644 --- a/packages/README.md +++ b/packages/README.md @@ -29,6 +29,8 @@ dsh-session ← dsh-llm, dsh-brand dsh-system-prompt ← dsh-llm dsh-agent ← dsh-llm, dsh-session, dsh-brand dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent +dsh-skill ← dsh-llm, dsh-agent +dsh-tool-skill ← dsh-skill, dsh-tools, dsh-agent, dsh-llm dsh-bash-local ← dsh-bash (BashExecutor impl) dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) dsh-llm-deepseek ← dsh-llm (DeepSeek adapter) @@ -44,7 +46,7 @@ dsh-subagent-spawn ← dsh-subagent, dsh-agent, dsh-session, dsh-llm (in-proces dsh-subagent-fork ← dsh-subagent-spawn, dsh-agent, dsh-session (in-process child seeded from parent log) dsh-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk (out-of-process child over ACP) dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent (model-facing delegation tool) -dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop (the providerless spine, as one bundle plugin) +dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-skill, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-tool-skill, dsh-agent-loop (the providerless spine, as one bundle plugin) dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin) dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin) ``` @@ -59,6 +61,8 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `session/` | `core` | Event-sourced session log + in-memory store | `ctx.sessions` | | `system-prompt/` | `core` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | | `tools/` | `core` | Tool registry + `tools/execute` waterfall | `ctx.tools` | +| `skill/` | `core` | Skill discovery + request-time model listing | `ctx.skills` | +| `tool-skill/` | `core` | Model-facing `skill` loader tool | (registers on `ctx.tools`) | | `agent/` | `core` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | | `agent-loop/` | `core` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | | `agent-core/` | `core` | Bundle plugin: the providerless/executor-less/UI-less spine as code (forwards `agent-loop`'s `agents`) | (loads the spine) | diff --git a/packages/core/README.md b/packages/core/README.md index 8d8805471a..1ac57d868b 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -7,10 +7,12 @@ The packages every harness build is assembled from: the session log, the system- | `session/` | Event-sourced session log + in-memory store | `ctx.sessions` | | `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | | `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` | +| `skill/` | Agent skill discovery + request-time skill listing | `ctx.skills` | +| `tool-skill/` | Model-facing `skill` loader tool | (registers on `ctx.tools`) | | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | | `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | | `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) | `agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable. -`agent-core` is the composition counterpart: one bundle plugin that loads the whole providerless spine (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds only the swappable backends. It lives in `core/` because it composes exclusively `core/` + interface packages and ships no provider, executor, or UI of its own. +`agent-core` is the composition counterpart: one bundle plugin that loads the whole providerless spine (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + `tool-skill` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds only the swappable backends. It lives in `core/` because it composes exclusively `core/` + interface packages and ships no provider, executor, or UI of its own. diff --git a/packages/core/agent-core/package.json b/packages/core/agent-core/package.json index a70ee30e71..2d01ec5900 100644 --- a/packages/core/agent-core/package.json +++ b/packages/core/agent-core/package.json @@ -28,8 +28,10 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tool-bash": "^0.0.1", + "@deepseek-ai/dsh-tool-skill": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, @@ -40,8 +42,10 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts index ad3f5d8c46..47617b1308 100644 --- a/packages/core/agent-core/src/index.ts +++ b/packages/core/agent-core/src/index.ts @@ -3,8 +3,8 @@ * * Loads the fixed set of services every harness agent needs — `timer`, the LLM * service, the session store, system-prompt assembly, the tool registry, the - * agent registry, the dev-mode invariants, the model-facing `bash` tool - * schemas, and the concrete `agent-loop` — and forwards the loop's `agents` + * skill registry, the agent registry, the dev-mode invariants, the model-facing + * `bash` and `skill` tool schemas, and the concrete `agent-loop` — and forwards the loop's `agents` * list as its OWN config (default `[]`), so each app supplies its own * pre-created agents. * @@ -48,9 +48,11 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' +import SkillService from '@deepseek-ai/dsh-skill' import AgentRegistry from '@deepseek-ai/dsh-agent' import * as invariants from '@deepseek-ai/dsh-invariants' import * as toolBash from '@deepseek-ai/dsh-tool-bash' +import * as toolSkill from '@deepseek-ai/dsh-tool-skill' import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop' export const name = 'agent-core' @@ -81,8 +83,10 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(SessionStore) ctx.plugin(SystemPrompt) ctx.plugin(ToolRegistry) + ctx.plugin(SkillService) ctx.plugin(AgentRegistry) ctx.plugin(invariants) ctx.plugin(toolBash) + ctx.plugin(toolSkill) ctx.plugin(AgentLoop, { agents: config.agents }) } diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index 67f5d88532..4a8954f9c6 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from 'vitest' +import { mkdtemp } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import * as agentCore from '../src/index.ts' @@ -15,12 +18,22 @@ import { AgentId } from '@deepseek-ai/dsh-agent' * bin smokes; here we assert the composition + config forwarding. */ async function mount(config?: agentCore.Config): Promise { + const oldDshHome = process.env.DSH_HOME + process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-')) const ctx = new Context() - await ctx.plugin(agentCore, config) - // The bundle mounts its children inside apply() (not awaited there); let their - // fibers settle so the spine services and any pre-created agent are ready. - await new Promise(resolve => setTimeout(resolve, 50)) - return ctx + try { + await ctx.plugin(agentCore, config) + // The bundle mounts its children inside apply() (not awaited there); let their + // fibers settle so the spine services and any pre-created agent are ready. + await new Promise(resolve => setTimeout(resolve, 50)) + return ctx + } finally { + if (oldDshHome === undefined) { + delete process.env.DSH_HOME + } else { + process.env.DSH_HOME = oldDshHome + } + } } describe('dsh-agent-core bundle', () => { @@ -32,11 +45,25 @@ describe('dsh-agent-core bundle', () => { expect(ctx.get('sessions')).toBeDefined() expect(ctx.get('systemPrompt')).toBeDefined() expect(ctx.get('tools')).toBeDefined() + expect(ctx.get('skills')).toBeDefined() expect(ctx.get('agents')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() await ctx.fiber.dispose() }) + it('includes the default skill system and skill tool', async () => { + const ctx = await mount() + + expect(ctx.skills).toBeDefined() + expect(ctx.tools.schemas().map(tool => tool.name)).toContain('skill') + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(expect.arrayContaining([ + 'dsh-plugin-creator', + 'dsh-skill-creator', + ])) + + await ctx.fiber.dispose() + }) + it('defaults the agents list to empty (no pre-created agents)', async () => { const ctx = await mount() expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() diff --git a/packages/core/agent-core/tsconfig.json b/packages/core/agent-core/tsconfig.json index 83bf06c586..6b53f62024 100644 --- a/packages/core/agent-core/tsconfig.json +++ b/packages/core/agent-core/tsconfig.json @@ -26,6 +26,12 @@ { "path": "../../core/tools" }, + { + "path": "../../core/skill" + }, + { + "path": "../../core/tool-skill" + }, { "path": "../../core/agent" }, diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 4892b357bf..a477921c98 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -8,7 +8,7 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -- `ctx.agentLoop.create(id: string, options?: AgentOptions): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-` (no cwd). Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. +- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-` with optional session metadata. Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): @@ -29,11 +29,12 @@ interface Config { id: string // required model?: string systemPrompt?: string + cwd?: string // optional workspace cwd for the fresh session }> } ``` -Agents listed in config are auto-created at startup. +Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. ### Classes diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index ab95ea5aac..73811b192d 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -12,7 +12,7 @@ import { randomUUID } from 'node:crypto' import z from 'schemastery' import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' @@ -33,6 +33,8 @@ export interface Config { /** Agents created from configuration at startup. */ agents: (AgentOptions & { id: AgentId + /** Optional workspace cwd for the config-created fresh session. */ + cwd?: string /** * If set, the config agent RESUMES this persisted session id instead of * starting a fresh `${id}-session-`. Sourced from an env var in @@ -73,6 +75,7 @@ export class AgentLoop extends Service implements AgentFactory { id: z.string().required(), model: z.string(), systemPrompt: z.string(), + cwd: z.string(), resumeSessionId: z.string(), })).default([]), }) as unknown as z @@ -82,7 +85,7 @@ export class AgentLoop extends Service implements AgentFactory { // Provide the agent-creation factory to the registry (effect-scoped: the // slot is cleared on dispose). ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()') - for (const { id, resumeSessionId, ...options } of config.agents) { + for (const { id, cwd, resumeSessionId, ...options } of config.agents) { if (resumeSessionId !== undefined && resumeSessionId !== '') { // Resume a prior session instead of starting fresh. resume() needs // `ctx.sessionPersistence`, which may load AFTER this plugin (cordis.yml @@ -101,15 +104,15 @@ export class AgentLoop extends Service implements AgentFactory { return () => void fiber.dispose() }, `agentLoop.resume(${id})`) } else { - this.create(id, options) + this.create(id, options, cwd === undefined ? {} : { cwd }) } } } /** * Config-driven create: an agent on a FRESH, non-colliding session id per run - * (`${id}-session-`, no cwd). Used for `cordis.yml`-configured agents - * and as the shared core for the programmatic factory {@link createAgent}. + * (`${id}-session-`). Used for `cordis.yml`-configured agents and as + * the shared core for the programmatic factory {@link createAgent}. * * Why a per-run id, not a fixed `${id}-session`: once a durable persistence * backend is loaded, a fixed id collides on the second run — the backend @@ -122,13 +125,13 @@ export class AgentLoop extends Service implements AgentFactory { * else start fresh) or an explicit caller-chosen session id — revisit when the * UI/ACP path owns session selection. */ - create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent { + create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { this.assertAgentIdFree(id) // Config/programmatic path: prepare the session and let start() fold its // lifecycle into the agent's composite effect (so a fiber unload tears the // session + agent down as one ordered chain, capturing the loop's closing // flush). The whole effect is owned by THIS fiber; no AgentHandle is needed. - const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta: {} }) + const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta }) const { agent } = this.start(id, options, session) return agent } diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index d018eff7a2..fdb6672bac 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -677,6 +677,21 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) }) + it('attaches config agent cwd to the fresh session header', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { + agents: [{ id: AgentId('config-agent'), model: 'mock', systemPrompt: '', cwd: '/work/project' }], + }) + + const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent + expect(agent.session.header.cwd).toBe('/work/project') + }) + it('replays a session log into an identical derived history', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', { text: 'x' }), diff --git a/packages/core/skill/README.md b/packages/core/skill/README.md new file mode 100644 index 0000000000..5fcb144adc --- /dev/null +++ b/packages/core/skill/README.md @@ -0,0 +1,41 @@ +# @deepseek-ai/dsh-skill + +Agent skill discovery and model-facing skill guidance. + +## Service: `SkillService` (ctx key: `skills`) + +### Public API + +- `ctx.skills.list({ cwd? })` Returns model-invocable skill summaries for the current workspace. +- `ctx.skills.get(name, { cwd? })` Returns the full skill, including disabled-for-model skills. +- `ctx.skills.register(skill): () => void` Registers a runtime skill, disposed with the calling fiber. + +### Discovery + +Default roots are resolved in this conflict priority order: + +| Source | Path | +|---|---| +| Project DSH | `/.dsh/skills` | +| Project agents | `/.agents/skills` | +| Runtime | `ctx.skills.register(...)` | +| User DSH | `~/.dsh/skills` | +| User agents | `~/.agents/skills` | +| Extra | `Config.extraRoots` | +| System | `~/.dsh/skills/.system` | + +The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips `.system` during normal user scanning so system skills are read exactly once. Same-name skills keep the highest-priority copy, then model-visible summaries are sorted by skill name for stable prompts and provider prefix-cache friendliness. + +Discovery is memoized per resolved root set and runtime-skill revision. Runtime `register()` and disposer calls invalidate the cache; disk-only changes are picked up on the next invalidation or process restart. + +## Skill Format + +Skills can be single-level directory bundles (`/SKILL.md`) or flat Markdown files (`.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter requires `name` and `description`; `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names must be kebab-case. + +## Prompt Integration + +The service listens on `agent/request` and appends a short `## Skills` listing to the request system prompt for the calling agent's cwd. The listing contains only stable routing metadata (`name`, `source`, `description`, and optional `whenToUse`), not skill bodies or local absolute paths. `description` and `whenToUse` are whitespace-normalized and capped in the listing so one pathological skill cannot bloat every model request. Models load full instructions through the `skill` tool. + +## System Skills + +On startup, the service ensures bundled system skills exist under `~/.dsh/skills/.system` unless `installSystemSkills: false` is configured. Project, runtime, user, and extra-root skills can override system skills by name. diff --git a/packages/core/skill/package.json b/packages/core/skill/package.json new file mode 100644 index 0000000000..f16ef58135 --- /dev/null +++ b/packages/core/skill/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-skill", + "description": "Agent skill discovery and prompt listing for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "yaml": "^2.4.2" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/core/skill/src/index.ts b/packages/core/skill/src/index.ts new file mode 100644 index 0000000000..631d84a415 --- /dev/null +++ b/packages/core/skill/src/index.ts @@ -0,0 +1,425 @@ +/** + * Agent skill discovery and prompt listing. + * + * Skills are progressive-disclosure instructions: the model sees only a short + * listing in the system prompt, then calls the `skill` tool to load the full + * `SKILL.md` body when a task matches. + * + * @module @deepseek-ai/dsh-skill + */ + +import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { homedir } from 'node:os' +import { Context, Service } from 'cordis' +import { parse as parseYaml } from 'yaml' +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-agent' + +const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +const MAX_PROMPT_FIELD_LENGTH = 500 + +export function isSkillName(name: string): boolean { + return SKILL_NAME.test(name) +} + +export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'extra' | 'system' + +export interface SkillSummary { + name: string + description: string + whenToUse?: string + disableModelInvocation?: boolean + directory: string + source: SkillSource +} + +export interface SkillDefinition extends SkillSummary { + content: string + path?: string + metadata?: Record +} + +export type SkillRegistration = Omit & { + disableModelInvocation?: boolean +} + +export interface SkillLookupOptions { + cwd?: string | undefined +} + +export interface Config { + /** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Shared agent config root. Defaults to `~/.agents`. */ + agentsHome?: string + /** Extra skill roots, scanned after user roots and before system skills. */ + extraRoots?: string[] + /** Ensure bundled system skills exist under `/skills/.system`. Defaults true. */ + installSystemSkills?: boolean +} + +declare module 'cordis' { + interface Context { + skills: SkillService + } +} + +interface SkillRoot { + path: string + source: SkillSource + skipSystem?: boolean +} + +const SYSTEM_SKILLS: SkillDefinition[] = [ + { + name: 'dsh-plugin-creator', + description: 'Create or update DeepSeek Harness Cordis plugins and packages.', + directory: 'system://dsh-plugin-creator', + source: 'system', + content: [ + 'Use this skill to create DeepSeek Harness plugins that fit the repository architecture.', + '', + 'Prefer Cordis services, plugin packages, effect-scoped registrations, and existing extension seams over loop changes.', + 'When adding a swappable capability, design the interface/implementation/consumer split first.', + 'Every registry or registration path needs disposal/HMR coverage.', + 'Update package docs, architecture docs, package graph references, and generated catalogs when public surfaces change.', + ].join('\n'), + }, + { + name: 'dsh-skill-creator', + description: 'Create or update DeepSeek Harness SKILL.md instructions.', + whenToUse: 'Use when writing reusable agent instructions for DeepSeek Harness or adding system/project/user skills.', + directory: 'system://dsh-skill-creator', + source: 'system', + content: [ + 'Use this skill to write focused DeepSeek Harness skills.', + '', + 'A skill is a directory `/SKILL.md` or a flat `.md` file with YAML frontmatter.', + 'Frontmatter must include kebab-case `name` and a concise `description` that tells the model when to load it.', + 'Use optional `whenToUse` for extra routing signal and `disableModelInvocation: true` for user-only skills.', + 'Keep the body procedural, evidence-oriented, and scoped to the workflow the skill owns.', + ].join('\n'), + }, +] + +export class SkillService extends Service { + private readonly dshHome: string + private readonly agentsHome: string + private readonly extraRoots: string[] + private readonly installSystemSkills: boolean + private readonly runtime = new Map() + private readonly collectCache = new Map>() + private runtimeRevision = 0 + private systemReady: Promise | undefined + + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'skills') + this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')) + this.agentsHome = resolve(config.agentsHome ?? join(homedir(), '.agents')) + this.extraRoots = (config.extraRoots ?? []).map(root => resolve(root)) + this.installSystemSkills = config.installSystemSkills ?? true + if (this.installSystemSkills) { + const systemRoot = join(this.dshHome, 'skills/.system') + this.systemReady = writeSystemSkills(systemRoot, this.ctx).catch((error: unknown) => { + this.ctx.logger.warn(`failed to install bundled system skills under ${systemRoot}: ${errorMessage(error)}`) + }) + } + + ctx.on('agent/request', async (agent, _turn, _step, _request, next) => { + const listing = await this.renderModelListing({ cwd: agent.session.header.cwd }) + const result = await next() + if (listing.length > 0) appendSystem(result, listing) + return result + }) + } + + register(skill: SkillRegistration): () => void { + const normalized = normalizeSkill(skill) + const dispose = this.ctx.effect(function* (this: SkillService) { + this.runtime.set(normalized.name, normalized) + this.invalidateCache() + yield () => { + this.runtime.delete(normalized.name) + this.invalidateCache() + } + }.bind(this), 'skills.register()') + return () => void dispose() + } + + async list(options: SkillLookupOptions = {}): Promise { + return (await this.collect(options)) + .filter(skill => skill.disableModelInvocation !== true) + .map(toSummary) + .sort(compareSummary) + } + + async get(name: string, options: SkillLookupOptions = {}): Promise { + if (!isSkillName(name)) return undefined + return (await this.collect(options)).find(skill => skill.name === name) + } + + async renderModelListing(options: SkillLookupOptions = {}): Promise { + const skills = await this.list(options) + if (skills.length === 0) return '' + const entries = skills.map((skill) => { + const lines = [ + ``, + `description: ${promptLine(skill.description)}`, + ...skill.whenToUse ? [`whenToUse: ${promptLine(skill.whenToUse)}`] : [], + '', + ] + return lines.join('\n') + }).join('\n') + return [ + '## Skills', + 'Available skills are listed below. Load a skill with the `skill` tool before following its instructions; do not infer or follow instructions from a skill body that has not been loaded.', + '', + entries, + '', + ].join('\n') + } + + private async collect(options: SkillLookupOptions): Promise { + await this.ensureSystemSkills() + const roots = await this.roots(options.cwd) + const key = collectCacheKey(roots, this.runtimeRevision) + const cached = this.collectCache.get(key) + if (cached !== undefined) return cached + + const collected = this.collectFresh(roots) + this.collectCache.set(key, collected) + return collected + } + + private async collectFresh(roots: { project: SkillRoot[]; shared: SkillRoot[] }): Promise { + const seen = new Set() + const result: SkillDefinition[] = [] + + const add = (skill: SkillDefinition): void => { + if (seen.has(skill.name)) { + this.ctx.logger.warn(`skill "${skill.name}" from ${skill.directory} ignored because a higher-priority skill already exists`) + return + } + seen.add(skill.name) + result.push(skill) + } + + for (const root of roots.project) { + for (const skill of await discoverRoot(root, this.ctx)) add(skill) + } + for (const skill of [...this.runtime.values()].sort((a, b) => a.name.localeCompare(b.name))) add(skill) + for (const root of roots.shared) { + for (const skill of await discoverRoot(root, this.ctx)) add(skill) + } + return result + } + + private async roots(cwd: string | undefined): Promise<{ project: SkillRoot[]; shared: SkillRoot[] }> { + const project: SkillRoot[] = [] + if (cwd !== undefined) { + const projectRoot = await findProjectRoot(resolve(cwd)) + project.push( + { path: join(projectRoot, '.dsh/skills'), source: 'project-dsh' }, + { path: join(projectRoot, '.agents/skills'), source: 'project-agents' }, + ) + } + const shared: SkillRoot[] = [ + { path: join(this.dshHome, 'skills'), source: 'user-dsh', skipSystem: true }, + { path: join(this.agentsHome, 'skills'), source: 'user-agents' }, + ...this.extraRoots.map(path => ({ path, source: 'extra' as const })), + { path: join(this.dshHome, 'skills/.system'), source: 'system' }, + ] + return { project, shared } + } + + private ensureSystemSkills(): Promise { + return this.systemReady ?? Promise.resolve() + } + + private invalidateCache(): void { + this.runtimeRevision += 1 + this.collectCache.clear() + } +} + +async function writeSystemSkills(systemRoot: string, ctx: Context): Promise { + await mkdir(systemRoot, { recursive: true }) + await Promise.all(SYSTEM_SKILLS.map(async (skill) => { + const dir = join(systemRoot, skill.name) + const file = join(dir, 'SKILL.md') + try { + await access(file) + return + } catch { + // Expected first-run path: the bundled system skill has not been installed. + } + await mkdir(dir, { recursive: true }) + await writeFile(file, renderSkillFile(skill)) + ctx.logger.debug(`installed system skill ${skill.name} at ${file}`) + })) +} + +function renderSkillFile(skill: SkillDefinition): string { + const frontmatter = [ + '---', + `name: ${skill.name}`, + `description: ${skill.description}`, + ...skill.whenToUse ? [`whenToUse: ${skill.whenToUse}`] : [], + '---', + '', + ] + return `${frontmatter.join('\n')}${skill.content}\n` +} + +async function discoverRoot(root: SkillRoot, ctx: Context): Promise { + let entries + try { + entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' }) + } catch { + return [] + } + + const skills: SkillDefinition[] = [] + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (root.skipSystem && entry.name === '.system') continue + const fullPath = join(root.path, entry.name) + const parsed = entry.isDirectory() + ? await parseSkillFile(join(fullPath, 'SKILL.md'), fullPath, root.source, ctx) + : entry.isFile() && entry.name.endsWith('.md') + ? await parseSkillFile(fullPath, root.path, root.source, ctx) + : undefined + if (parsed) skills.push(parsed) + } + return skills +} + +async function parseSkillFile(path: string, directory: string, source: SkillSource, ctx: Context): Promise { + let raw: string + try { + raw = await readFile(path, 'utf8') + } catch { + return undefined + } + const parsed = parseFrontmatter(raw) + if (!parsed) { + ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`) + return undefined + } + const name = stringField(parsed.data, 'name') + const description = stringField(parsed.data, 'description') + if (name === undefined || description === undefined) { + ctx.logger.warn(`skill file ${path} ignored: frontmatter requires name and description`) + return undefined + } + if (!isSkillName(name)) { + ctx.logger.warn(`skill file ${path} ignored: invalid skill name "${name}"`) + return undefined + } + return { + name, + description, + ...optionalString(parsed.data, 'whenToUse'), + ...optionalBoolean(parsed.data, 'disableModelInvocation'), + ...optionalMetadata(parsed.data), + directory, + path, + source, + content: parsed.body.trim(), + } +} + +function parseFrontmatter(raw: string): { data: Record; body: string } | undefined { + if (!raw.startsWith('---\n')) return undefined + const end = raw.indexOf('\n---', 4) + if (end < 0) return undefined + const yaml = raw.slice(4, end) + const bodyStart = raw.indexOf('\n', end + 4) + const parsed = parseYaml(yaml) as unknown + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined + return { data: parsed as Record, body: bodyStart < 0 ? '' : raw.slice(bodyStart + 1) } +} + +async function findProjectRoot(cwd: string): Promise { + let current = cwd + while (true) { + try { + await access(join(current, '.git')) + return current + } catch { + // Continue walking upward until a git root is found. + } + const parent = dirname(current) + if (parent === current) return cwd + current = parent + } +} + +function normalizeSkill(skill: SkillRegistration): SkillDefinition { + if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`) + if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`) + return { ...skill, source: skill.source } +} + +function toSummary(skill: SkillDefinition): SkillSummary { + const { name, description, whenToUse, disableModelInvocation, directory, source } = skill + return { + name, + description, + ...whenToUse !== undefined ? { whenToUse } : {}, + ...disableModelInvocation !== undefined ? { disableModelInvocation } : {}, + directory, + source, + } +} + +function compareSummary(left: SkillSummary, right: SkillSummary): number { + return left.name.localeCompare(right.name) +} + +function promptLine(value: string): string { + const normalized = value.replaceAll(/\s+/g, ' ').trim() + if (normalized.length <= MAX_PROMPT_FIELD_LENGTH) return normalized + return `${normalized.slice(0, MAX_PROMPT_FIELD_LENGTH - 3)}...` +} + +function stringField(data: Record, key: string): string | undefined { + const value = data[key] + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function optionalString(data: Record, key: string): { [K in typeof key]?: string } { + const value = data[key] + return typeof value === 'string' && value.length > 0 ? { [key]: value } : {} +} + +function optionalBoolean(data: Record, key: string): { [K in typeof key]?: boolean } { + const value = data[key] + return typeof value === 'boolean' ? { [key]: value } : {} +} + +function optionalMetadata(data: Record): { metadata?: Record } { + const value = data.metadata + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return { metadata: value as Record } + } + return {} +} + +function escapeAttr(value: string): string { + return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<') +} + +function collectCacheKey(roots: { project: SkillRoot[]; shared: SkillRoot[] }, runtimeRevision: number): string { + return JSON.stringify({ runtimeRevision, roots }) +} + +function errorMessage(error: unknown): string { + return String(error) +} + +function appendSystem(request: GenerateOptions, text: string): void { + request.system = [request.system ?? '', text].filter(part => part.length > 0).join('\n\n') +} + +export default SkillService diff --git a/packages/core/skill/tests/skill.spec.ts b/packages/core/skill/tests/skill.spec.ts new file mode 100644 index 0000000000..659e4c8b74 --- /dev/null +++ b/packages/core/skill/tests/skill.spec.ts @@ -0,0 +1,342 @@ +import { describe, expect, it } from 'vitest' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { Context } from 'cordis' +import SkillService from '@deepseek-ai/dsh-skill' + +async function tempDir(name: string): Promise { + return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) +} + +async function writeSkill(root: string, name: string, description: string, body = 'Use the skill.'): Promise { + const dir = join(root, name) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`) +} + +async function writeFlatSkill(root: string, name: string, description: string, body = 'Flat body.'): Promise { + await mkdir(root, { recursive: true }) + await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`) +} + +describe('SkillService', () => { + it('discovers project, user, agents, and system skill roots in priority order', async () => { + const home = await tempDir('skill-home') + const agentsHome = await tempDir('agents-home') + const project = await tempDir('skill-project') + await mkdir(join(project, '.git'), { recursive: true }) + + await writeSkill(join(home, '.dsh/skills/.system'), 'same', 'system skill') + await writeSkill(join(agentsHome, '.agents/skills'), 'same', 'user agents skill') + await writeSkill(join(home, '.dsh/skills'), 'same', 'user dsh skill') + await writeSkill(join(project, '.agents/skills'), 'same', 'project agents skill') + await writeSkill(join(project, '.dsh/skills'), 'same', 'project dsh skill') + await writeSkill(join(home, '.dsh/skills/.system'), 'system-only', 'system only') + + const ctx = new Context() + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(agentsHome, '.agents'), installSystemSkills: false }) + + const skills = await ctx.skills.list({ cwd: join(project, 'src') }) + expect(skills.map(skill => [skill.name, skill.description])).toEqual([ + ['same', 'project dsh skill'], + ['system-only', 'system only'], + ]) + expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh') + }) + + it('sorts the final model-visible list by skill name after priority conflict resolution', async () => { + const home = await tempDir('skill-sorted-home') + const agentsHome = await tempDir('skill-sorted-agents') + const project = await tempDir('skill-sorted-project') + await mkdir(join(project, '.git'), { recursive: true }) + + await writeSkill(join(project, '.dsh/skills'), 'z-project', 'Project skill') + await writeSkill(join(home, '.dsh/skills'), 'm-user', 'User skill') + await writeSkill(join(home, '.dsh/skills/.system'), 'a-system', 'System skill') + await writeSkill(join(home, '.dsh/skills/.system'), 'm-user', 'Shadowed system skill') + + const ctx = new Context() + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(agentsHome, '.agents'), installSystemSkills: false }) + + expect((await ctx.skills.list({ cwd: project })).map(skill => [skill.name, skill.description])).toEqual([ + ['a-system', 'System skill'], + ['m-user', 'User skill'], + ['z-project', 'Project skill'], + ]) + }) + + it('gives project skills priority over runtime skills while runtime overrides user and system skills', async () => { + const home = await tempDir('skill-runtime-priority') + const project = await tempDir('skill-runtime-project') + await mkdir(join(project, '.git'), { recursive: true }) + + await writeSkill(join(project, '.dsh/skills'), 'project-name', 'Project wins') + await writeSkill(join(home, '.dsh/skills'), 'runtime-name', 'User loses') + await writeSkill(join(home, '.dsh/skills/.system'), 'runtime-name', 'System loses') + + const ctx = new Context() + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }) + ctx.skills.register({ + name: 'project-name', + description: 'Runtime loses to project', + content: 'Runtime body.', + directory: 'memory://project-name', + source: 'runtime', + }) + ctx.skills.register({ + name: 'runtime-name', + description: 'Runtime wins', + content: 'Runtime body.', + directory: 'memory://runtime-name', + source: 'runtime', + }) + + expect((await ctx.skills.get('project-name', { cwd: project }))?.description).toBe('Project wins') + expect((await ctx.skills.get('runtime-name', { cwd: project }))?.description).toBe('Runtime wins') + }) + + it('does not scan .system twice through the user dsh root', async () => { + const home = await tempDir('skill-system') + await writeSkill(join(home, '.dsh/skills/.system'), 'builtin', 'builtin skill') + + const ctx = new Context() + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['builtin']) + }) + + it('parses flat skills and filters invalid or model-disabled skills from listing', async () => { + const home = await tempDir('skill-flat') + await writeFlatSkill(join(home, '.dsh/skills'), 'flat-skill', 'flat description', 'Flat instructions.') + await writeFile(join(home, '.dsh/skills/bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad') + await writeFile(join(home, '.dsh/skills/missing-description.md'), '---\nname: missing-description\n---\n\nbad') + await writeFile(join(home, '.dsh/skills/no-frontmatter.md'), 'No frontmatter.') + await writeFile(join(home, '.dsh/skills/open-frontmatter.md'), '---\nname: open-frontmatter') + await writeFile(join(home, '.dsh/skills/non-object.md'), '---\n[]\n---\n\nbad') + await writeFile(join(home, '.dsh/skills/no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---') + await writeFile(join(home, '.dsh/skills/notes.txt'), 'ignored') + await mkdir(join(home, '.dsh/skills/not-a-skill'), { recursive: true }) + await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'hidden description', 'Hidden.') + await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\n') + + const ctx = new Context() + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body']) + expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.') + expect(await ctx.skills.get('Bad_Name')).toBeUndefined() + }) + + it('renders no model listing when no model-invocable skills exist', async () => { + const home = await tempDir('skill-empty-listing') + const ctx = new Context() + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }) + + expect(await ctx.skills.renderModelListing()).toBe('') + const request = { model: 'm', messages: [], system: 'base' } + const result = await ctx.waterfall('agent/request', { + session: { header: { cwd: home } }, + } as never, 1, 1, request, () => Promise.resolve(request)) + expect(result.system).toBe('base') + }) + + it('installs system skills into the DSH home without overwriting existing files', async () => { + const home = await tempDir('skill-install') + const existing = join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md') + await mkdir(join(home, '.dsh/skills/.system/dsh-plugin-creator'), { recursive: true }) + await writeFile(existing, '---\nname: dsh-plugin-creator\ndescription: Custom system skill\n---\n\nCustom body.\n') + + const ctx = new Context() + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }) + + expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([ + ['dsh-plugin-creator', 'Custom system skill'], + ['dsh-skill-creator', 'Create or update DeepSeek Harness SKILL.md instructions.'], + ]) + expect(await readFile(existing, 'utf8')).toContain('Custom body.') + expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('dsh-skill-creator') + }) + + it('degrades when bundled system skill installation fails', async () => { + const home = await tempDir('skill-install-fail') + await writeFile(join(home, '.dsh'), 'not a directory') + await writeSkill(join(home, '.agents/skills'), 'fallback-skill', 'Fallback skill') + + const ctx = new Context() + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['fallback-skill']) + }) + + it('memoizes disk discovery until runtime skill registrations change', async () => { + const home = await tempDir('skill-cache') + await writeSkill(join(home, '.dsh/skills'), 'initial-skill', 'Initial skill') + + const ctx = new Context() + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill']) + await writeSkill(join(home, '.dsh/skills'), 'late-skill', 'Late skill') + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill']) + + const dispose = ctx.skills.register({ + name: 'runtime-skill', + description: 'runtime', + content: 'Runtime body.', + directory: 'memory://runtime', + source: 'runtime', + }) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill', 'late-skill', 'runtime-skill']) + + dispose() + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill', 'late-skill']) + }) + + it('includes extra roots, optional metadata, and explicit false disable flags', async () => { + const home = await tempDir('skill-extra') + const extra = await tempDir('skill-extra-root') + await writeFile(join(extra, 'extra-skill.md'), [ + '---', + 'name: extra-skill', + 'description: Extra skill', + 'whenToUse: For extra-root tests', + 'disableModelInvocation: false', + 'metadata:', + ' owner: tests', + '---', + '', + 'Extra body.', + ].join('\n')) + + const ctx = new Context() + await ctx.plugin(SkillService, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + extraRoots: [extra], + installSystemSkills: false, + }) + + expect(await ctx.skills.list()).toEqual([{ + name: 'extra-skill', + description: 'Extra skill', + whenToUse: 'For extra-root tests', + disableModelInvocation: false, + directory: extra, + source: 'extra', + }]) + expect((await ctx.skills.get('extra-skill'))?.metadata).toEqual({ owner: 'tests' }) + expect(await ctx.skills.renderModelListing()).toContain('whenToUse: For extra-root tests') + }) + + it('bounds prompt listing fields without changing stored skill content', async () => { + const home = await tempDir('skill-prompt-bounds') + const longDescription = 'a'.repeat(600) + await writeSkill(join(home, '.dsh/skills'), 'long-skill', longDescription, 'Full body.') + + const ctx = new Context() + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }) + + const listing = await ctx.skills.renderModelListing() + expect(listing).toContain(`${'a'.repeat(497)}...`) + expect(listing).not.toContain('a'.repeat(600)) + expect((await ctx.skills.get('long-skill'))?.description).toBe(longDescription) + }) + + it('adds skill guidance through the agent/request waterfall without including bodies', async () => { + const home = await tempDir('skill-guidance') + await writeSkill(join(home, '.dsh/skills'), 'research-helper', 'Research helper', 'Long body that must not be listed.') + + const ctx = new Context() + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }) + + const request = await ctx.waterfall('agent/request', { + session: { header: { cwd: home } }, + } as never, 1, 1, { model: 'm', messages: [], system: 'base' }, () => Promise.resolve({ model: 'm', messages: [], system: 'base' })) + + expect(request.system ?? '').toContain('## Skills\n') + expect(request.system ?? '').toContain('research-helper') + expect(request.system ?? '').toContain('source="project-dsh"') + expect(request.system ?? '').not.toContain(home) + expect(request.system ?? '').not.toContain('Long body') + expect((request.system ?? '').match(/## Skills/g)).toHaveLength(1) + + const sameObject = { model: 'm', messages: [], system: 'base' } + const sameObjectResult = await ctx.waterfall('agent/request', { + session: { header: { cwd: home } }, + } as never, 1, 1, sameObject, () => Promise.resolve(sameObject)) + expect(sameObjectResult.system).toContain('## Skills') + + const requestWithoutBase = await ctx.waterfall('agent/request', { + session: { header: { cwd: home } }, + } as never, 1, 1, { model: 'm', messages: [] }, () => Promise.resolve({ model: 'm', messages: [] })) + expect(requestWithoutBase.system).toContain('## Skills') + + const copyCtx = new Context() + await copyCtx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }) + copyCtx.on('agent/request', async (_agent, _turn, _step, requestToCopy) => ({ ...requestToCopy })) + const copiedRequest = await copyCtx.waterfall('agent/request', { + session: { header: { cwd: home } }, + } as never, 1, 1, { model: 'm', messages: [], system: 'base' }, () => Promise.resolve({ model: 'm', messages: [], system: 'base' })) + expect((copiedRequest.system ?? '').match(/## Skills/g)).toHaveLength(1) + }) + + it('cleans up runtime registered skills when the contributing fiber is disposed', async () => { + const ctx = new Context() + const home = await tempDir('skill-runtime') + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }) + + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.skills.register({ + name: 'runtime-skill', + description: 'runtime', + content: 'Runtime body.', + directory: 'memory://runtime', + source: 'runtime', + }) + }, { inject: ['skills'] })) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['runtime-skill']) + await fiber.dispose() + expect(await ctx.skills.list()).toEqual([]) + }) + + it('removes runtime registered skills when the returned disposer is called', async () => { + const home = await tempDir('skill-runtime-disposer') + const ctx = new Context() + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }) + + const dispose = ctx.skills.register({ + name: 'manual-dispose', + description: 'manual', + content: 'Manual body.', + directory: 'memory://manual', + source: 'runtime', + }) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['manual-dispose']) + dispose() + expect(await ctx.skills.list()).toEqual([]) + }) + + it('rejects invalid runtime skill registrations', async () => { + const home = await tempDir('skill-runtime-invalid') + const ctx = new Context() + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }) + + expect(() => ctx.skills.register({ + name: 'Bad_Name', + description: 'bad', + content: 'bad', + directory: 'memory://bad', + source: 'runtime', + })).toThrow('invalid skill name') + expect(() => ctx.skills.register({ + name: 'empty-description', + description: '', + content: 'bad', + directory: 'memory://bad', + source: 'runtime', + })).toThrow('requires a description') + }) +}) diff --git a/packages/core/skill/tsconfig.json b/packages/core/skill/tsconfig.json new file mode 100644 index 0000000000..2ec8481fcd --- /dev/null +++ b/packages/core/skill/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" }, + { "path": "../agent" } + ] +} diff --git a/packages/core/tool-skill/README.md b/packages/core/tool-skill/README.md new file mode 100644 index 0000000000..21f296ba66 --- /dev/null +++ b/packages/core/tool-skill/README.md @@ -0,0 +1,15 @@ +# @deepseek-ai/dsh-tool-skill + +The model-facing `skill` tool for loading full skill instructions. + +Requires `ctx.tools` and `ctx.skills` (`inject: ['tools', 'skills']`). + +## Tool: `skill` + +| Arg | Type | Notes | +|---|---|---| +| `name` | string (required) | Exact kebab-case skill name from the available skills listing. | + +Execution uses the calling agent's `session.header.cwd` to resolve project-local skills. A successful call returns a text block containing ``, the skill body, the skill base directory, and relative-path guidance. Unknown names, invalid names, and skills marked `disableModelInvocation: true` return `isError` tool results through the normal tool registry error path. + +The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context. diff --git a/packages/core/tool-skill/package.json b/packages/core/tool-skill/package.json new file mode 100644 index 0000000000..508323d729 --- /dev/null +++ b/packages/core/tool-skill/package.json @@ -0,0 +1,38 @@ +{ + "name": "@deepseek-ai/dsh-tool-skill", + "description": "Model-facing skill loading tool for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-skill": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/core/tool-skill/src/index.ts b/packages/core/tool-skill/src/index.ts new file mode 100644 index 0000000000..faa5fb2243 --- /dev/null +++ b/packages/core/tool-skill/src/index.ts @@ -0,0 +1,52 @@ +/** + * Model-facing `skill` tool. + * + * @module @deepseek-ai/dsh-tool-skill + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { isSkillName, type SkillDefinition } from '@deepseek-ai/dsh-skill' + +export const name = 'tool-skill' +export const inject = ['tools', 'skills'] + +export function apply(ctx: Context): void { + const skillTool = defineTool({ + name: 'skill', + description: 'Load the full instructions for one available skill by name. Use this when the current task matches a skill listed in the system prompt.', + parameters: { + name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' }, + }, + async execute(args, exec) { + if (!isSkillName(args.name)) { + throw new Error(`invalid skill name "${args.name}"`) + } + const skill = await ctx.skills.get(args.name, { cwd: exec.agent?.session.header.cwd }) + if (!skill) { + throw new Error(`unknown skill "${args.name}"`) + } + if (skill.disableModelInvocation === true) { + throw new Error(`skill "${args.name}" is not available for model invocation`) + } + return [{ type: 'text', text: renderSkillContent(skill) }] + }, + presentCall(args) { + return { title: `Load skill ${args.name}`, kind: 'read', rawInput: args.name } + }, + }) + ctx.tools.register(skillTool) +} + +function renderSkillContent(skill: SkillDefinition): string { + return [ + ``, + `# Skill: ${skill.name}`, + '', + skill.content, + '', + `Base directory for this skill: ${skill.directory}`, + 'Resolve relative files mentioned by this skill against the base directory before using them.', + '', + ].join('\n') +} diff --git a/packages/core/tool-skill/tests/tool-skill.spec.ts b/packages/core/tool-skill/tests/tool-skill.spec.ts new file mode 100644 index 0000000000..711a176f65 --- /dev/null +++ b/packages/core/tool-skill/tests/tool-skill.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import SkillService from '@deepseek-ai/dsh-skill' +import * as toolSkill from '@deepseek-ai/dsh-tool-skill' + +async function tempDir(name: string): Promise { + return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) +} + +async function writeSkill(root: string, name: string, description: string, body: string): Promise { + const dir = join(root, name) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`) +} + +async function setup(home: string): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }) + await ctx.plugin(toolSkill) + return ctx +} + +describe('dsh-tool-skill', () => { + it('registers the skill tool schema and removes it on dispose', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + const home = await tempDir('tool-schema') + await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }) + + const fiber = await ctx.plugin(toolSkill) + expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill']) + expect(ctx.tools.get('skill')?.presentCall?.({ name: 'project-skill' })).toEqual({ + title: 'Load skill project-skill', + kind: 'read', + rawInput: 'project-skill', + }) + await fiber.dispose() + expect(ctx.tools.schemas()).toEqual([]) + }) + + it('loads a skill for the calling agent cwd', async () => { + const home = await tempDir('tool-load') + const project = await tempDir('tool-project') + await mkdir(join(project, '.git'), { recursive: true }) + await writeSkill(join(project, '.dsh/skills'), 'project-skill', 'Project skill', 'Project instructions.') + const ctx = await setup(home) + + const result = await ctx.tools.execute({ + callId: CallId('c1'), + name: 'skill', + arguments: { name: 'project-skill' }, + agent: { session: { header: { cwd: project } } } as never, + }) + + expect(result.isError).toBe(false) + const block = result.content[0] + expect(block?.type).toBe('text') + if (block?.type !== 'text') throw new Error('expected text skill result') + expect(block.text).toContain('') + expect(block.text).toContain('Project instructions.') + }) + + it('returns isError for unknown, invalid, and model-disabled skills', async () => { + const home = await tempDir('tool-errors') + await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.') + await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n') + const ctx = await setup(home) + + const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } }) + const invalid = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } }) + const disabled = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } }) + + expect(unknown.isError).toBe(true) + expect(invalid.isError).toBe(true) + expect(disabled.isError).toBe(true) + }) +}) diff --git a/packages/core/tool-skill/tsconfig.json b/packages/core/tool-skill/tsconfig.json new file mode 100644 index 0000000000..d36e8a449c --- /dev/null +++ b/packages/core/tool-skill/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" }, + { "path": "../agent" }, + { "path": "../skill" }, + { "path": "../tools" } + ] +} diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index a78df0fa72..9c62595aa9 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -11,7 +11,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | Plugin | Why it is here | |---|---| | `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) | -| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model`/`systemPrompt` | +| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model`/`systemPrompt` with `process.cwd()` as the fresh session cwd | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-ui-stdio` | the readline UI, bound to the `main` agent | @@ -29,6 +29,8 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | +Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-agent` was started. Resumed sessions keep the cwd stored in the persisted session header. + ## The bin `dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages) through its internal module loader, which is only active under that flag. The `demo:echo` / `demo:coding` scripts invoke it that way. diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index c4b9ed202c..9d91134278 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -48,8 +48,10 @@ export const name = 'stdio-agent' /** * App config: the swappable per-demo values, each routed to where the app wires * it. `model`/`systemPrompt`/`resumeSessionId` configure the pre-created `main` - * agent (through {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); - * `persistenceRoot` is the JSONL backend's directory; `welcome` is the UI banner. + * agent (through {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list). + * Fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions + * keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory; + * `welcome` is the UI banner. */ export interface Config { /** Model name for the `main` agent (must have a registered adapter). */ @@ -90,6 +92,7 @@ export function apply(ctx: Context, config: Config): void { id: AgentId('main'), model: config.model, systemPrompt: config.systemPrompt, + cwd: process.cwd(), ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, }], }) diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index f72de0a1da..b3b2d9ac2a 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -34,7 +34,9 @@ describe('dsh-stdio-agent app', () => { expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('sessionPersistence')).toBeDefined() // The pre-created `main` agent the UI drives. - expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + const agent = ctx.get('agents')?.get(AgentId('main')) + expect(agent).toBeDefined() + expect(agent?.session.header.cwd).toBe(process.cwd()) await ctx.fiber.dispose() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55e565cad2..7ad0f1f89d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,12 +153,18 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../session + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../skill '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../tool-skill '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../tools @@ -212,6 +218,22 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/core/skill: + dependencies: + yaml: + specifier: ^2.4.2 + version: 2.9.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/core/system-prompt: devDependencies: '@deepseek-ai/dsh-llm': @@ -221,6 +243,24 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/core/tool-skill: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../skill + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/core/tools: devDependencies: '@deepseek-ai/dsh-agent': diff --git a/tsconfig.build.json b/tsconfig.build.json index 4c71d2f14e..8c008704cf 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -19,6 +19,8 @@ { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/core/tools" }, + { "path": "./packages/core/skill" }, + { "path": "./packages/core/tool-skill" }, { "path": "./packages/core/agent-loop" }, { "path": "./packages/core/agent-core" }, { "path": "./packages/bash/bash" }, diff --git a/tsconfig.json b/tsconfig.json index dcc23b2fbe..46ed4e35ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,8 @@ { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/core/tools" }, + { "path": "./packages/core/skill" }, + { "path": "./packages/core/tool-skill" }, { "path": "./packages/core/agent-loop" }, { "path": "./packages/core/agent-core" }, { "path": "./packages/bash/bash" },