3. Services

A service is a named capability one plugin provides and other plugins consume through ctx. In the harness, ctx.tools, ctx.llm, and ctx.agents are services. A consumer names the capability, such as 'tools', rather than importing its provider, so configuration can select a provider without changing the consumer.

Provide a service

Create greeter.ts in tmp/cordis-tutorial:

import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context {
    greeter: GreeterService
  }
}

export class GreeterService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'greeter')
  }

  greet(who: string) {
    return `Hello, ${who}!`
  }
}

export const name = 'greeter'

export function apply(ctx: Context) {
  ctx.plugin(GreeterService)
}

Two pieces work together:

  • Runtime: super(ctx, 'greeter') registers the instance under the name greeter. From then on, any plugin can reach it as ctx.greeter. The registration is an effect — unloading the provider removes the service.
  • Compile time: the declare module '@deepseek-ai/cordis' block is TypeScript declaration merging. It adds greeter to the Context interface so ctx.greeter typechecks everywhere. It generates no code; without it the service still works at runtime, but consumers lose type safety.

A Service subclass is itself a plugin (the class form from chapter 1), so ctx.plugin(GreeterService) mounts it like any other.

Consume a service with inject

Create consumer.ts:

import type { Context } from '@deepseek-ai/cordis'

export const name = 'consumer'
export const inject = ['greeter']

export function apply(ctx: Context) {
  console.log(ctx.greeter.greet('world'))
}

inject lists the services this plugin requires. Cordis holds the plugin in PENDING until every listed service exists, so inside apply, ctx.greeter is guaranteed ready. Load order in cordis.yml does not matter — dependencies, not file order, decide when plugins start.

Compose and run:

- name: './greeter.ts'
- name: './consumer.ts'
Hello, world!

Swap the two lines in cordis.yml and rerun: same output. Try removing ./greeter.ts entirely: the consumer stays PENDING and prints nothing — no crash, no partial run. A PENDING fiber does not keep Node's event loop alive either, so a composition with nothing else running exits 0 silently. Chapter 6 shows how to diagnose that state.

Dependencies are tracked after load

inject is not a one-shot boot check. If a required service disappears while the app runs — its provider was unloaded or hot-replaced — every dependent plugin is unloaded too, and loads again when the service returns. Combined with effects (chapter 2), this prevents a running consumer from retaining a reference to an unavailable service: its own registrations are unwound when the dependency disappears.

This is also why service replacement works in config: unload the dsh-bash-local entry, mount a different shell provider, and every plugin injecting 'shell' cleanly restarts against the new implementation.

Optional dependencies

inject is for hard requirements. For a capability the plugin can live without, skip inject and probe at the use site:

export function apply(ctx: Context) {
  // undefined when no provider is loaded; the plugin still runs.
  const greeter = ctx.get('greeter')
  console.log(greeter?.greet('maybe') ?? 'no greeter available')
}

Naming

Service names live in one flat namespace per application. Prefix or namespace your own services distinctively (the harness claims plain names like tools and llm); the generated cordis-surface regions on the subsystem pages list every name the harness registers.

Next: Events — communication without a shared service.

3. 服务

服务是一个插件提供、其他插件通过 ctx 消费的具名能力。在 harness 中,ctx.toolsctx.llmctx.agents 都是服务。消费方只指定 'tools' 之类的能力,而不导入其提供方,因此配置可以选择提供方,无需修改消费方。

提供服务

创建 greeter.ts,将它放在 tmp/cordis-tutorial 中:

import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context {
    greeter: GreeterService
  }
}

export class GreeterService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'greeter')
  }

  greet(who: string) {
    return `Hello, ${who}!`
  }
}

export const name = 'greeter'

export function apply(ctx: Context) {
  ctx.plugin(GreeterService)
}

两部分协同工作:

  • 运行时super(ctx, 'greeter') 以名称 greeter 注册该实例。此后,任何插件都可以通过 ctx.greeter 访问它。注册属于 effect,卸载提供方时会移除该服务。
  • 编译时declare module '@deepseek-ai/cordis' 块使用 TypeScript 声明合并,把 greeter 加入 Context 接口,使 ctx.greeter 在各处都能通过类型检查。它不会生成代码;没有该声明时,服务在运行时仍能工作,但消费方会失去类型安全。

Service 子类本身就是插件(第 1 章介绍的类形态),因此 ctx.plugin(GreeterService) 会像挂载其他插件一样挂载它。

使用 inject 消费服务

创建 consumer.ts

import type { Context } from '@deepseek-ai/cordis'

export const name = 'consumer'
export const inject = ['greeter']

export function apply(ctx: Context) {
  console.log(ctx.greeter.greet('world'))
}

inject 列出该插件需要的服务。Cordis 会让插件保持 PENDING,直到列出的每项服务都存在,因此在 apply 内可以保证 ctx.greeter 已经就绪。cordis.yml 中的加载顺序无关紧要:决定插件何时启动的是依赖关系,而不是文件顺序。

组合并运行:

- name: './greeter.ts'
- name: './consumer.ts'
Hello, world!

交换 cordis.yml 中两行的顺序后重新运行,输出仍然相同。尝试彻底移除 ./greeter.ts:消费方会保持 PENDING,不输出任何内容,既不崩溃,也不会只运行一部分。处于 PENDING 的 fiber 也不会让 Node 的事件循环保持活跃,因此如果组合中没有其他运行项,进程会静默地以状态码 0 退出。第 6 章介绍如何诊断这种状态。

加载后仍会跟踪依赖关系

inject 并非一次性的启动检查。如果应用运行期间所需服务消失,例如提供方被卸载或热替换,每个依赖插件也会随之卸载,并在服务恢复后再次加载。结合 effect(第 2 章),这能防止运行中的消费方保留对不可用服务的引用:依赖消失时,它自己的注册也会撤销。

这也是配置中可以替换服务的原因:卸载 Cordis 配置项 dsh-bash-local,挂载另一个 shell 提供方,所有注入 'shell' 的插件都会重新启动并使用新实现。

可选依赖

inject 用于硬性依赖。如果某项功能缺失时插件仍可运行,请跳过 inject,并在使用处探测:

export function apply(ctx: Context) {
  // undefined when no provider is loaded; the plugin still runs.
  const greeter = ctx.get('greeter')
  console.log(greeter?.greet('maybe') ?? 'no greeter available')
}

命名

每个应用中的服务名称共用一个扁平命名空间。请为自有服务添加有辨识度的前缀或命名空间(harness 已占用 toolsllm 等普通名称);子系统页面上生成的 cordis-surface 区块列出 harness 注册的每个名称。

下一章:事件:无需共享服务即可通信。