Skip to content

feat(harness): @jambonz/sdk/harness skeleton custom harness - #25

Merged
davehorton merged 4 commits into
mainfrom
feat/agent-engines
Oct 29, 2026
Merged

davehorton merged 4 commits into
mainfrom
feat/agent-engines

Conversation

@davehorton

Copy link
Copy Markdown
Contributor

What

@jambonz/sdk/harness: a skeleton custom harness for the agent verb (llm.vendor: "harness"), per AGENT-ENGINES-DESIGN §6 and Appendix A. createHarness() speaks the whole harness WebSocket protocol (harness.jambonz.org, protocol 1) and works unchanged as a pass-through to an OpenAI-compatible chat model; a developer adds code only through hooks.

API

import { createHarness } from '@jambonz/sdk/harness';

const h = createHarness({
  apiKey,                     // optional: require Authorization: Bearer <apiKey> on the upgrade
  model, instructions,        // default model gpt-4.1-mini
  greeting,                   // optional fixed greeting text (else the model greets)
  client: { apiKey, baseURL, headers }, // OpenAI-compatible; defaults OPENAI_API_KEY / OPENAI_BASE_URL
  modelParams, maxToolRounds, errorReply, logger /* pino */,
  onSessionStart(session, ctx) {},
  beforeModel(turn, ctx) { return { instructions, messages, tools /* names */, model, route, interruptible } /* or { reply } */; },
  tools: { name: { description, parameters, run(args, ctx) } },
  checkSentence(sentence, ctx) { return true /* | false (stop) | 'replacement' */; },
  afterTurn(turn, ctx) {},
  onEvent: { dtmf(e, ctx) {}, silence(e, ctx) {}, external(e, ctx) {}, /* any A.7 name */ },
  onSessionEnd(end, ctx) {},
});
await h.listen(8080);         // or { port, host }; resolves { port, host }
await h.close();

ctx: callSid, session (the session:start message), state (Map), history (user/assistant/tool messages), say(text, {interrupt, interruptible}) → reply id, command(name, args) → Promise<{ok, error?}>, callTool(name, args) (Studio tool via tool:call), record(field, value), log (pino child with call_sid).

Also exported: every protocol message type for both directions (SessionStartMessage, TurnStartMessage, ReplyChunkMessage, TurnRecord, HarnessEvent, ...), createModelClient, SentenceSplitter.

Built-in behaviour: history; chunks passed through as they arrive (sentence-by-sentence synthesized chunks when checkSentence is set); turn:cancel (and a superseding turn:start) aborts the model stream and silences the turn; barge-in trims history to user_interruption.spoken; tool loop over own tools and session:start.tools (Studio tools via tool:call/tool:result), with a cancelled turn's pending tool calls answered so history stays valid; greeting turn; silence/no_input policy (ask once, then goodbye and hangup after the goodbye is spoken; override with onEvent.silence); A.13 turn record on every reply:done (route, steps with timings for beforeModel/model/tool/output_check, model_calls with tokens via stream_options.include_usage, own_tools, ctx.record fields; truncated with a warning over 16 KB); resume on session:start {resumed: true} within 10 s; close 4000 on an unsupported protocol version; unknown message types ignored; GET /health.

Failures are logged with call_sid/turn_id: rejected upgrades (401/400 with reason), model errors (also turn_record.error, plus a spoken errorReply), hook exceptions, failed commands and tools, error events from jambonz, socket drops.

New dependency: pino ^9 (Node 18 compatible). The model client uses global fetch (no openai dependency).

How tested

typescript/test/harness.test.ts (22 tests) runs the real server over real WebSockets against a fake jambonz client and a fake OpenAI-compatible SSE server: greeting turn and chunk pass-through, fixed greeting, speech turn history, turn:cancel aborts the stream plus barge-in trimming, beforeModel routing / reply without model, Studio tool round trip, Studio tool error, cancel during a tool call, developer tool + own_tools, checkSentence false stops / string replaces, turn_record fields with afterTurn, model failure, commands from onEvent, default silence and no_input policies, onEvent.silence override, unknown types ignored, protocol 4000, auth rejected/accepted, subprotocol required, sentence splitter.

npm run typecheck, npm test (155 passing) and npm run build pass. npm run lint fails on main already (eslint is neither installed nor configured; CI runs typecheck + test only), so it was not used.

The example was also packed against the built SDK, typechecked with module: Node16, and driven end to end (greeting → routed outage turn → own tool → sentence replacement → turn record).

Run the example

cd examples/harness
(cd ../../typescript && npm ci && npm run build) && npm install ../../typescript   # until published
npm install
OPENAI_API_KEY=sk-... HARNESS_API_KEY=$(openssl rand -hex 24) PORT=8080 npm start

Then add a Harness LLM credential in jambonz with URL ws(s)://host:8080 and API key = HARNESS_API_KEY, and use it from an agent verb with llm.vendor: "harness". See examples/harness/README.md.

Not implemented

  • A.10 starting early: speech:start / transcript are received and ignored; no hook exposes them yet.
  • application_update (e.g. inject_context, generate_reply) has no default behaviour; it reaches onEvent.application_update only.
  • The starter project/container image mentioned in §6 (only the example project is included).

🤖 Generated with Claude Code

davehorton and others added 4 commits October 28, 2026 10:50
…e agent verb

createHarness() implements the jambonz harness WebSocket protocol
(harness.jambonz.org, protocol 1) and works as a pass-through to an
OpenAI-compatible chat model; developers add code only through hooks
(onSessionStart, beforeModel, tools, checkSentence, afterTurn, onEvent,
onSessionEnd).

- typed protocol messages for both directions
- bearer-key auth on upgrade, subprotocol check, close 4000 on an
  unsupported protocol version, GET /health
- history, streaming chunks, sentence checks, turn:cancel aborts the
  model stream, barge-in trims history to what was heard
- tool loop for own tools and the agent's Studio tools (tool:call)
- greeting turn, default silence/no_input policy, A.13 turn record
- resume on session:start {resumed: true} within 10s
- examples/harness and a README section
- tests over real WebSockets with a fake jambonz client and a fake
  OpenAI-compatible streaming server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ons types

- label on the llm/s2s verbs: with no auth, the account's speech-to-speech
  credential with that label (schema 0.4.33)
- paxaOptions on the recognizer, which the schema already had
- schema-drift tests pass again (156)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The schema-drift test failed after the 0.4.36 bump: the schema's rime_s2s
verb had no TypeScript counterpart. Added like gptlive_s2s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@davehorton
davehorton merged commit 251b820 into main Oct 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant