스폰서LobeHubLobeHub자세히 알아보기
dshfind

제 1 과: 첫 번째 플러그인: Hello, DSH!

한 줄 요약: DSH에서 에이전트에 「인사」 플러그인을 추가할 때 소스를 fork할 필요가 없습니다——nameapply(ctx)를 익스포트하는 TypeScript 모듈을 작성하고, cordis.yml에 등록한 뒤 dsh를 시작하면 즉시 적용됩니다. 어셈블리에서 빼고 다시 시작하면 플러그인이 등록한 모든 것도 원래대로 복원됩니다. 이것이 여러분이 직접 작성한 첫 번째 플러그인입니다.


1. 사용자 스토리: 에이전트에게 인사 가르치기

이전 과(제 3 장 · 제 11 과)에서는 DSH 패키지를 밖에서 안으로 해부해 보았습니다: 디렉터리, 파일, 컴포넌트 정의, fiber. 이번 과부터는 직접 실습합니다——여러분은 DSH가 설치된 컴퓨터 앞에 앉아 있고, Web UI가 http://127.0.0.1:3080에서 실행 중입니다. 문득 작은 소원이 생깁니다:

에이전트가 시작될 때마다 먼저 「Hello, DSH!」라고 인사하게 하고 싶다.

전통적인 프레임워크에서는 소스를 fork하고, 메인 루프를 수정하고, 다시 컴파일해야 할 수도 있습니다. DSH에서는 세 가지만 하면 됩니다:

  1. 코드 작성: 플러그인을 담은 TypeScript 파일을 새로 만듭니다;
  2. 등록: cordis.yml이라는 어셈블리 파일에 등록합니다;
  3. 시작: 프레임워크가 플러그인을 로드하면 기능이 즉시 적용됩니다.

이것이 제 4 장 전체의 주된 흐름입니다. 이 단계들을 그림으로 그리면, 앞으로 계속 반복하게 될 플러그인 개발 루프가 됩니다:

① 写代码inject / apply / ctx.use② 注册cordis.yml / profile③ 加载运行dsh 启动即生效④ 调试日志 / 事件改代码 → 热模块替换,无需重启

写插件 = 声明「需要什么 / 贡献什么」,注册即加载,加载即生效

그림의 마지막 단계에 주목하세요: 코드 수정 → 핫 모듈 교체, 재시작 불필요. 플러그인을 수정하면 시스템이 핫 업데이트하므로 계속 재시작할 필요가 없습니다——이것은 제 4 장 뒷부분의 「핫 교체」 과의 주인공이므로, 이번 과에서는 먼저 앞의 세 단계를 통과해 봅시다.


2. 환경 준비: 리포지토리 클론, 빌드, dsh 명령 확보

플러그인을 작성하려면 먼저 DSH 본체가 필요합니다. 공식 퀵스타트가 제시하는 체크리스트는 다음과 같습니다(출처: docs/user/guide/index.zh.md):

필요한 것버전
Node.js^22.19 또는 >= 24
pnpm11(Corepack으로 활성화)
API 키DeepSeek Platform의 DEEPSEEK_API_KEY

순서대로 실행합니다:

git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git
cd deepseek-harness
pnpm install
pnpm run build

리포지토리 루트에 Git에서 무시되는 .env를 만들고 키를 입력합니다:

DEEPSEEK_API_KEY=sk-your-key-here

그런 다음 환경이 준비되었는지 확인합니다:

pnpm run dsh web

http://127.0.0.1:3080을 여세요——브라우저에 Web UI가 나타나면 개발 환경에서 DSH를 실행할 수 있는 상태입니다.

💡 플러그인 개발 튜토리얼은 퀵스타트를 완료한 리포지토리 체크아웃에서 시작한다고 가정합니다(출처: docs/user/develop/basic/index.zh.md). 즉, 먼저 실행되게 한 다음 개발하는 것입니다. 이후의 모든 명령은 리포지토리 루트에서 실행한다고 가정합니다.


3. 최소 플러그인 코드: name, apply, inject

3.1 플러그인이란: apply를 익스포트하는 모듈

공식 튜토리얼의 정의입니다(출처: docs/user/develop/basic/index.zh.md):

Harness에서 플러그인은 apply 함수를 익스포트하는 TypeScript 모듈입니다. 프레임워크는 로드할 때 apply를 호출하면서 ctx(컨텍스트 객체)를 전달하고, 여러분은 ctx를 통해 기능을 등록합니다.

가장 간단한 플러그인은 이렇게 생겼습니다——이것이 전체 구조입니다:

import type { Context } from 'cordis'

export const name = 'my-plugin'

export function apply(ctx: Context) {
  // Register capabilities here.
}

(출처: docs/user/develop/basic/index.zh.md)

세 가지 요소를 하나씩 분해해 봅시다:

요소무엇인가한 마디로
name플러그인의 이름. 로더가 진단 시 플러그인을 식별하는 데 사용「나는 누구인가」
apply(ctx)프레임워크가 로드할 때 호출하는 이펙트 함수「무엇을 기여하는가」——ctx에 기능을 등록
ctx컨텍스트 객체. 플러그인과 시스템이 공유하는 「공용 칠판」「어디에 있고, 무엇을 건드릴 수 있는가」

📝 참고: Cordis 튜토리얼에 따륩 name은 선택적인 표시용 메타데이터로, 진단 정보에서 플러그인을 식별하는 데만 사용됩니다(출처: docs/cordis-tutorial/01-first-plugin.zh.md). 하지만 항상 작성하는 것을 권장합니다——플러그인이 많아지면 로그에서 누가 누구인지 알아보는 것이 중요해집니다.

3.2 플러그인이 정말 「인사」하게 하기: 우리의 hello-plugin

패턴을 그대로 따라 인사하는 플러그인을 작성해 봅시다(출처: docs/user/develop/basic/index.zh.md):

import type { Context } from 'cordis'

export const name = 'hello-plugin'

export function apply(ctx: Context) {
  // Required dependencies are ready before apply runs.
  console.log('[hello-plugin] plugin loaded!')
}

이것이 바로 우리가 원하는 「Hello, DSH!」입니다——프레임워크가 플러그인을 로드할 때 apply를 호출하면 console.log가 터미널에 한 줄을 출력합니다. 「프레임워크를 시작하는」 코드를 작성할 필요가 없습니다. 플러그인은 자신의 기여만 기술하고, 조합은 어셈블리 파일에 맡깁니다(이것은 Cordis 튜토리얼 제 1 장의 원문입니다. 출처: docs/cordis-tutorial/01-first-plugin.zh.md).

3.3 다른 플러그인의 도움이 필요할 때: inject 한 줄 추가

플러그인이 다른 플러그인이 제공하는 기능(예: 도구 레지스트리 tools)을 사용해야 한다면 inject 한 줄을 추가합니다(출처: docs/user/develop/basic/index.zh.md):

import type { Context } from 'cordis'

export const name = 'my-tool-plugin'
export const inject = ['tools']

export function apply(ctx: Context) {
  // ctx.tools is ready here.
  ctx.tools.register(/* ... */)
}

inject는 「나는 이것들에 의존한다」는 뜻입니다——프레임워크는 이 의존성들이 준비된 후에야 여러분의 apply를 실행한다고 보장합니다. 이전 과에서 배웠듯이: 컴포넌트 정의 = inject(의존성 선언 d) + apply(이펙트 함수 e)라는 두 장의 설계도이며, 프레임워크는 ctx.use로 이 설계도를 라이프사이클을 가진 fiber로 인스턴스화합니다. 언로드할 때 fiber의 dispose가 플러그인이 등록한 모든 것을 자동으로 취소합니다. 여러분이 작성하는 apply는 「무엇을 기여하는가」만 담당하고, 라이프사이클은 전적으로 프레임워크에 맡깁니다.

💡 이 최소 골격을 기억하세요: name + inject + apply. hello-plugin에는 앞의 두 개만 필요합니다. 나중에 도구 플러그인을 작성할 때 injectctx.tools.register가 등장합니다.

3.4 실제 패키지의 물리적 구조: 어디에 두는가

hello-plugin은 임시 디렉터리에 작성하는 것으로 충분합니다. 하지만 플러그인을 리포지토리의 정식 패키지로 만들고 싶다면, 실전 매뉴얼이 파일별 체크리스트를 제공합니다(출처: docs/cookbook/adding-a-package.zh.md):

packages/<group>/<pkg>/
  package.json     # 패키지 이름, 의존성, 빌드 산출물 엔트리
  tsconfig.json    # TypeScript 컴파일 설정
  src/index.ts     # service 기본 익스포트 또는 플러그인(name/inject/apply/Config)
  README.md        # 서비스 API, 이벤트, 확장 포인트, 설계 설명

여기서 <group>은 순수한 컨테이너성 그룹(core, llm, bash, subagent, todo, util 등)이며, package.json에는 엄격한 불변 조건이 있습니다: private: true, type: module, main: "lib/index.js", types: "lib/types/index.d.ts", 그리고 cordis가 peerDependencies와 devDependencies 양쪽에 모두 나타나야 합니다.

3.5 실제 프로덕션 플러그인의 모습: agent-spine-demo

「최소」라는 말에 겁먹지 마세요——실제 DSH 플러그인도 본질적으로는 같은 name + apply이고, apply 안에서 하는 일이 많을 뿐입니다. 리포지토리에는 실행 가능한 최소 예제 패키지 agent-spine-demo가 있는데, 이것은 에이전트 스파인 전체(수십 개의 서브 플러그인)를 복합 패키지로 마운트합니다(출처: packages/examples/agent-spine-demo/src/index.ts, 대부분의 자식 노드는 생략):

import type { Context } from 'cordis'
import Timer from '@cordisjs/plugin-timer'
import LlmService from '@deepseek-ai/dsh-llm'
// ...더 많은 서브 플러그인 import...

export const name = 'agent-spine-demo'

export function apply(ctx: Context, config: Config): void {
  // ...
  ctx.plugin(Timer)
  ctx.plugin(LlmService)
  ctx.plugin(AgentRegistry)
  ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
  // ...
}

두 가지에 주목하세요: 첫째, apply가 두 번째 매개변수 config를 받는다는 것——플러그인은 이것으로 사용자 설정을 받을 수 있습니다. 둘째, ctx.plugin(...)으로 다른 플러그인을 자식 노드로 마운트한다는 것——플러그인이 플러그인을 중첩할 수 있으며, 이것이 바로 「모든 것이 플러그인」의 조합 방식입니다. 이 패키지의 package.json도 3.4 절의 불변 조건을 확인시켜 줍니다(출처: packages/examples/agent-spine-demo/package.json):

{
  "name": "@deepseek-ai/dsh-agent-spine-demo",
  "type": "module",
  "main": "lib/index.js",
  "types": "lib/types/index.d.ts",
  "peerDependencies": {
    "cordis": "^4.0.0-rc.7"
  }
}

따라서 「플러그인 작성」과 「hello-plugin 작성」은 같은 일이고 규모만 다릅니다: 구조는 항상 name + inject + apply 입니다.


4. 어떻게 로드하는가: cordis.yml, profile, 그리고 dsh plugin add

4.1 로컬 개발: cordis.yml + --patch 오버레이

플러그인 파일은 작성했습니다. 이제 실행 중인 DSH에 어떻게 넣을까요? 로컬 개발에서는 **어셈블리 파일 cordis.yml**을 사용합니다. 리포지토리 루트에 임시 프로젝트를 만듭니다:

mkdir -p scratch-plugin/src

hello-plugin을 scratch-plugin/src/my-plugin.ts로 저장하고, scratch-plugin/cordis.yml을 생성한 뒤 insert로 어셈블리에 끼워 넣습니다(출처: docs/user/develop/basic/index.zh.md):

- insert:
    - id: hello
      name: './src/my-plugin.ts'

id는 어셈블리 안에서의 이름이고, name은 플러그인 모듈을 가리킵니다——상대 경로일 수도 있고 npm 패키지 이름일 수도 있습니다. 그런 다음 이 오버레이를 붙여 Web UI를 시작합니다:

pnpm run dsh web --patch ./scratch-plugin/cordis.yml

로더(Loader)가 cordis.yml을 읽고, ./src/my-plugin.ts를 해석하고, 서브 플러그인으로 마운트한 다음, Cordis가 여러분의 apply(ctx)를 호출합니다(출처: docs/cordis-tutorial/01-first-plugin.zh.md). http://127.0.0.1:3080을 여세요——시작하는 동안 터미널에 [hello-plugin] plugin loaded!가 출력됩니다——이것이 실제로 동작하는 「Hello, DSH!」입니다.

📝 cordis.yml의 각 항목은 동시에 시작되므로 목록에서의 위치가 로드 순서를 보장하지 않습니다. 실제 순서는 파일에서의 위치가 아니라 서비스 의존성(inject)에 의해 결정됩니다(출처: docs/cordis-tutorial/01-first-plugin.zh.md).

4.2 로드 방식들의 역할 분담

방식상황사용법
cordis.yml + --patch로컬 개발, 자신의 플러그인 시험pnpm run dsh web --patch ./scratch-plugin/cordis.yml
profile(설정 복합 패키지)일상적인 시작, 여러 플러그인 조합dsh --profile <name>이 manifest 순서대로 각 bundle의 패치 레이어를 합성
dsh plugin add정식으로 릴리스된 플러그인 패키지 설치패키지를 bundle로 묶고 dsh plugin add your-package로 profile에 설치

처음 두 가지는 이번 과에서 바로 사용할 수 있습니다. 세 번째는 「배포」 과의 내용이므로, 여기서는 이런 경로가 있다는 것만 알아 두세요(출처: docs/user/develop/basic/publish.zh.md): 복합 패키지로 패키징하고(package.jsondsh.bundle을 선언하여 cordis.patch.yml을 가리킴), npm에 릴리스하거나 tarball을 전달하면, 다른 사람이 dsh plugin add를 실행하기만 하면 설치됩니다. dsh plugin --profile demo add .는 로컬 체크아웃을 profile에 링크하고 dsh.profile.bundles에 추가합니다. dsh --profile demo --dump-config로 합성된 전체 설정을 미리 확인할 수 있습니다.

4.3 실행 검증: 로드하면 즉시 적용, 언로드하면 복원

이제 두 가지 검증을 해서 DSH의 가장 핵심적인 약속——「로드하면 즉시 적용, 언로드하면 복원」——을 체험해 봅시다(제 2 장의 논문과 제 3 장의 fiber 메커니즘과 호응합니다):

  1. 로드하면 즉시 적용: --patch를 붙여 시작하면 터미널에 즉시 [hello-plugin] plugin loaded!가 출력됩니다. 레지스트리도, 시스템 재시작도, 기존 코드 수정도 필요 없습니다——플러그인을 설치하면 기능이 바로 생깁니다.
  2. 언로드하면 복원: cordis.yml에서 insert 단락을 삭제하거나(--patch 인수를 빼거나) 다시 시작하면——터미널에 더 이상 출력되지 않고, 모든 것이 플러그인이 존재하기 전의 모습으로 돌아갑니다.

왜 「언로드하면 복원」이 가능할까요? 공식 튜토리얼의 원문입니다(출처: docs/user/develop/basic/index.zh.md):

ctx를 통해 등록된 모든 것——이벤트 리스너, 도구, 타이머——은 플러그인이 언로드될 때 자동으로 정리됩니다. 수동으로 removeListener나 clearInterval을 할 필요가 없습니다.

이것이 fiber의 dispose가 작동하는 모습입니다: console.log는 인사를 할 뿐이지만, 도구 등록, 이벤트 리스닝, 타이머 설정도 마찬가지입니다——모든 것이 장부에 기록되고 언로드 시 한 번에 정산됩니다. 수동 관리가 필요한 소수의 리소스(예: 네트워크 연결)만 ctx.effect()로 프레임워크에 정리 방법을 알려 줍니다.

🔁 제 2 장과 제 3 장에 대한 호응: 제 2 장에서는 Cordis의 「시공간 합성성」 약속이 설치한 것은 제거할 수 있고, 제거해도 흔적을 남기지 않는다라고 했고, 제 3 장에서는 이 약속이 fiber의 dispose로 실현된다고 했습니다——이제 여러분은 직접 이것을 검증했습니다. DSH가 에이전트의 「자기 참조 수정」을 허용하고 플러그인을 핫 업데이트할 수 있는 자신감의 근거가 모두 여기에 있습니다.


핵심 정리

  • 플러그인 = apply 함수를 익스포트하는 TypeScript 모듈: name(나는 누구인가), inject(무엇이 필요한가——프레임워크가 준비된 후에 실행됨을 보장), apply(무엇을 기여하는가——ctx에 기능을 등록).
  • 환경 준비: deepseek-harness-sdk 리포지토리 클론 → pnpm installpnpm run build.envDEEPSEEK_API_KEY 설정 → pnpm run dsh web으로 Web UI 열기.
  • 최소 hello-plugin: export const name = 'hello-plugin' + export function apply(ctx) { console.log('[hello-plugin] plugin loaded!') }——이것이 전부입니다.
  • 실제 패키지 구조: packages/<group>/<pkg>/ 아래의 package.json, tsconfig.json, src/index.ts, README.md. package.json에는 main / types / type: module / cordis 이중 의존성 등의 불변 조건이 있습니다. 실제 예시는 agent-spine-demo를 참조하세요(apply 안에서 ctx.plugin으로 서브 플러그인을 조합).
  • 로컬 로드: cordis.ymlinsert로 한 항목을 추가하고(id + 소스를 가리키는 name), pnpm run dsh web --patch ./scratch-plugin/cordis.yml로 시작합니다. 정식 설치는 bundle + dsh plugin add / profile로 진행합니다.
  • 로드하면 즉시 적용, 언로드하면 복원: ctx를 통해 등록된 모든 것은 플러그인 언로드 시 자동으로 정리되며, 수동 removeListener나 clearInterval이 필요 없습니다——이것이 Cordis의 「설치한 것은 제거할 수 있다」는 약속의 실현입니다.

🚀 축하합니다. 비록 인사밖에 할 수 없지만, DSH에서 첫 번째 플러그인을 직접 작성했습니다. 다음에는 플러그인이 진짜 일을 하게 해 봅시다: 모델이 호출할 수 있는 도구를 작성해서, 「Hello」를 「제가 도와드릴 수 있습니다」로 바꿔 봅시다. 다음 과에서 만나요!

셀프 테스트 · 첫 번째 플러그인

답을 모두 선택한 후 「답안 제출」을 클릭하면 정답 여부와 해설을 확인할 수 있습니다.

1. 플러그인(컴포넌트) 정의는 어떤 두 부분으로 구성됩니까?
2. apply 함수와 ctx에 대해 올바른 설명은 무엇입니까?
3. 로컬 개발에서 실행 중인 DSH에 플러그인을 로드하려면 어떻게 합니까?
4. 「로드하면 즉시 적용, 언로드하면 복원」은 무슨 뜻입니까?