Files
deepseek-harness/docs/cordis-tutorial/03-services.md
imccyu ec601ca13d build(vendor): rescope the vendored Cordis packages into @deepseek-ai
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it
prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`,
`verify-translation-pairing --write` for the touched bilingual pairs,
`gen-doc-graphs`, and one typert snapshot whose ids embed character offsets.
`pnpm run rescope-vendor --check` verifies the result.

Renames nine vendored packages (cordis, cosmokit, schemastery and the six
@cordisjs plugins) and every reference that resolves them: manifest names and
dependency keys, module specifiers including declare-module merges, cordis.yml
plugin names, tsconfig paths, every Markdown fence, and `docs/` prose.
Directory names, upstream versions, and dependency ranges are unchanged, so
vendor/README.md still reads as an upstream snapshot; its manifest table gains
an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed
at each fork's origin.

The tutorial tier follows the rename end to end: its yaml fences named plugins
the Loader can no longer resolve, its `ts ignore-check` fences disagreed with
the compiled fences beside them, and its prose quoted both. The contracts that
told readers to keep upstream names — the root convention and the vendoring
cookbook's tree comment and manifest invariant — now say to rescope instead.

Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle
purity gate now names the vendored libraries a browser bundle inlines, and the
files where a bare `cordis` is an agent-preset id keep that product data.
2026-08-10 22:04:13 +08:00

4.1 KiB

3. Services

English | 中文

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 bash provider, and every plugin injecting 'bash' 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.