Skip to content

Commit 75dc6e2

Browse files
committed
feat(protocol): add zero-dep @agentic-kit/protocol kernel package
Extract the shared protocol kernel — types, EventStream, and the usage/message/JSON/base-url helpers — into a standalone leaf package with no runtime dependencies, so every provider adapter can depend on it without pulling in the framework. Establishes a single source of truth for the contract that was previously copy-pasted across packages.
1 parent c2b13e6 commit 75dc6e2

15 files changed

Lines changed: 6201 additions & 9728 deletions

packages/protocol/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# @agentic-kit/protocol
2+
3+
The shared protocol kernel for [agentic-kit](https://github.com/constructive-io/agentic-kit).
4+
5+
This package holds the provider-agnostic contracts and helpers that every adapter
6+
and the top-level `agentic-kit` package build on:
7+
8+
- **Types**`Context`, `Message`, `AssistantMessage`, `ModelDescriptor`, `Usage`,
9+
content blocks, and the provider/stream interfaces.
10+
- **Event stream**`EventStream` / `createAssistantMessageEventStream` for
11+
incremental assistant responses.
12+
- **Message helpers**`createEmptyUsage`, `calculateUsageCost`, `getMessageText`,
13+
`normalizeContext`, `createAssistantMessage`.
14+
- **JSON helpers**`clone`, `parsePartialJson`, `completePartialJson` for snapshotting
15+
and recovering streamed tool-call arguments.
16+
- **Base URL**`normalizeBaseUrl`.
17+
18+
It has no runtime dependencies, so a provider adapter (`@agentic-kit/openai`,
19+
`@agentic-kit/anthropic`, `@agentic-kit/ollama`) can depend on it standalone without
20+
pulling in the rest of the framework.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {
2+
type AssistantMessage,
3+
calculateUsageCost,
4+
clone,
5+
completePartialJson,
6+
createAssistantMessageEventStream,
7+
createEmptyUsage,
8+
type ModelDescriptor,
9+
normalizeBaseUrl,
10+
parsePartialJson,
11+
} from '../src/index.js';
12+
13+
const model: ModelDescriptor = {
14+
id: 'test-model',
15+
name: 'Test',
16+
api: 'test-api',
17+
provider: 'test',
18+
baseUrl: 'https://example.com/v1',
19+
input: ['text'],
20+
reasoning: false,
21+
cost: { input: 3, output: 15 },
22+
};
23+
24+
describe('protocol kernel', () => {
25+
it('createEmptyUsage produces a fully zeroed usage', () => {
26+
expect(createEmptyUsage()).toEqual({
27+
input: 0,
28+
output: 0,
29+
reasoning: 0,
30+
cacheRead: 0,
31+
cacheWrite: 0,
32+
totalTokens: 0,
33+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
34+
});
35+
});
36+
37+
it('calculateUsageCost applies the per-million schedule', () => {
38+
const usage = createEmptyUsage();
39+
usage.input = 1_000_000;
40+
usage.output = 2_000_000;
41+
calculateUsageCost(model, usage);
42+
expect(usage.cost.input).toBeCloseTo(3);
43+
expect(usage.cost.output).toBeCloseTo(30);
44+
expect(usage.cost.total).toBeCloseTo(33);
45+
});
46+
47+
it('parsePartialJson recovers truncated tool arguments', () => {
48+
expect(parsePartialJson('{"city": "Paris')).toEqual({ city: 'Paris' });
49+
expect(parsePartialJson('{"items": [1, 2')).toEqual({ items: [1, 2] });
50+
expect(parsePartialJson('')).toEqual({});
51+
});
52+
53+
it('completePartialJson closes open brackets and strings', () => {
54+
expect(completePartialJson('{"a": [1, {"b": "x')).toBe('{"a": [1, {"b": "x"}]}');
55+
});
56+
57+
it('normalizeBaseUrl appends /v1 only when no version segment is present', () => {
58+
expect(normalizeBaseUrl('https://example.com')).toBe('https://example.com/v1');
59+
expect(normalizeBaseUrl('https://example.com/v1')).toBe('https://example.com/v1');
60+
expect(normalizeBaseUrl('https://example.com/v2/')).toBe('https://example.com/v2');
61+
});
62+
63+
it('clone deep-copies without sharing references', () => {
64+
const source = { a: { b: 1 }, list: [1, 2, 3] };
65+
const copy = clone(source);
66+
expect(copy).toEqual(source);
67+
expect(copy.a).not.toBe(source.a);
68+
});
69+
70+
it('EventStream yields events in order and resolves the terminal result', async () => {
71+
const stream = createAssistantMessageEventStream();
72+
const message: AssistantMessage = {
73+
role: 'assistant',
74+
content: [{ type: 'text', text: 'hi' }],
75+
api: model.api,
76+
provider: model.provider,
77+
model: model.id,
78+
usage: createEmptyUsage(),
79+
stopReason: 'stop',
80+
timestamp: Date.now(),
81+
};
82+
83+
stream.push({ type: 'start', partial: message });
84+
stream.push({ type: 'done', reason: 'stop', message });
85+
stream.end(message);
86+
87+
const seen: string[] = [];
88+
for await (const event of stream) {
89+
seen.push(event.type);
90+
}
91+
expect(seen).toEqual(['start', 'done']);
92+
await expect(stream.result()).resolves.toBe(message);
93+
});
94+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"compilerOptions": {
4+
"noEmit": true,
5+
"rootDir": "..",
6+
"types": ["jest", "node"]
7+
},
8+
"include": ["./**/*.ts", "../src/**/*.ts"],
9+
"exclude": ["../dist", "../node_modules"]
10+
}

packages/protocol/jest.config.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} */
2+
module.exports = {
3+
preset: 'ts-jest',
4+
testEnvironment: 'node',
5+
transform: {
6+
'^.+\\.tsx?$': [
7+
'ts-jest',
8+
{
9+
babelConfig: false,
10+
tsconfig: '__tests__/tsconfig.json',
11+
},
12+
],
13+
},
14+
transformIgnorePatterns: [`/node_modules/*`],
15+
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
16+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
17+
modulePathIgnorePatterns: ['dist/*'],
18+
moduleNameMapper: {
19+
'^(\\.{1,2}/.*)\\.js$': '$1'
20+
}
21+
};

packages/protocol/package.json

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"name": "@agentic-kit/protocol",
3+
"version": "1.0.0",
4+
"author": "Dan Lynch <pyramation@gmail.com>",
5+
"description": "Shared protocol kernel (types, event stream, message + JSON helpers) for agentic-kit",
6+
"main": "index.js",
7+
"module": "esm/index.js",
8+
"types": "index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./index.d.ts",
12+
"import": "./esm/index.js",
13+
"require": "./index.js"
14+
}
15+
},
16+
"homepage": "https://github.com/constructive-io/agentic-kit",
17+
"license": "SEE LICENSE IN LICENSE",
18+
"publishConfig": {
19+
"access": "public",
20+
"directory": "dist"
21+
},
22+
"repository": {
23+
"type": "git",
24+
"url": "https://github.com/constructive-io/agentic-kit"
25+
},
26+
"bugs": {
27+
"url": "https://github.com/constructive-io/agentic-kit/issues"
28+
},
29+
"scripts": {
30+
"clean": "makage clean",
31+
"prepack": "npm run build",
32+
"build": "makage build",
33+
"postbuild": "node ../../scripts/write-esm-package-json.js",
34+
"build:dev": "makage build --dev",
35+
"lint": "eslint . --fix",
36+
"test": "jest",
37+
"test:watch": "jest --watch"
38+
},
39+
"dependencies": {},
40+
"keywords": []
41+
}

packages/protocol/src/base-url.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function normalizeBaseUrl(baseUrl: string): string {
2+
const trimmed = baseUrl.replace(/\/+$/, '');
3+
if (/\/v\d+$/.test(trimmed)) {
4+
return trimmed;
5+
}
6+
return `${trimmed}/v1`;
7+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type {
2+
AssistantMessage,
3+
AssistantMessageEvent,
4+
AssistantMessageEventStream,
5+
} from './types.js';
6+
7+
export class EventStream<TEvent, TResult = TEvent> implements AsyncIterable<TEvent> {
8+
private readonly queue: TEvent[] = [];
9+
private readonly waiting: Array<(result: IteratorResult<TEvent>) => void> = [];
10+
private done = false;
11+
private readonly finalResultPromise: Promise<TResult>;
12+
private resolveFinalResult!: (value: TResult) => void;
13+
14+
constructor(
15+
private readonly isTerminal: (event: TEvent) => boolean,
16+
private readonly extractResult: (event: TEvent) => TResult
17+
) {
18+
this.finalResultPromise = new Promise<TResult>((resolve) => {
19+
this.resolveFinalResult = resolve;
20+
});
21+
}
22+
23+
push(event: TEvent): void {
24+
if (this.done) {
25+
return;
26+
}
27+
28+
if (this.isTerminal(event)) {
29+
this.done = true;
30+
this.resolveFinalResult(this.extractResult(event));
31+
}
32+
33+
const waiter = this.waiting.shift();
34+
if (waiter) {
35+
waiter({ value: event, done: false });
36+
return;
37+
}
38+
39+
this.queue.push(event);
40+
}
41+
42+
end(result?: TResult): void {
43+
this.done = true;
44+
if (result !== undefined) {
45+
this.resolveFinalResult(result);
46+
}
47+
48+
while (this.waiting.length > 0) {
49+
this.waiting.shift()!({ value: undefined as never, done: true });
50+
}
51+
}
52+
53+
async *[Symbol.asyncIterator](): AsyncIterator<TEvent> {
54+
while (true) {
55+
if (this.queue.length > 0) {
56+
yield this.queue.shift()!;
57+
continue;
58+
}
59+
60+
if (this.done) {
61+
return;
62+
}
63+
64+
const next = await new Promise<IteratorResult<TEvent>>((resolve) => {
65+
this.waiting.push(resolve);
66+
});
67+
68+
if (next.done) {
69+
return;
70+
}
71+
72+
yield next.value;
73+
}
74+
}
75+
76+
result(): Promise<TResult> {
77+
return this.finalResultPromise;
78+
}
79+
}
80+
81+
export class DefaultAssistantMessageEventStream
82+
extends EventStream<AssistantMessageEvent, AssistantMessage>
83+
implements AssistantMessageEventStream
84+
{
85+
constructor() {
86+
super(
87+
(event) => event.type === 'done' || event.type === 'error',
88+
(event) => {
89+
if (event.type === 'done') {
90+
return event.message;
91+
}
92+
93+
if (event.type === 'error') {
94+
return event.error;
95+
}
96+
97+
throw new Error('Unexpected terminal event');
98+
}
99+
);
100+
}
101+
}
102+
103+
export function createAssistantMessageEventStream(): DefaultAssistantMessageEventStream {
104+
return new DefaultAssistantMessageEventStream();
105+
}

packages/protocol/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './base-url.js';
2+
export * from './event-stream.js';
3+
export * from './json.js';
4+
export * from './messages.js';
5+
export * from './types.js';

packages/protocol/src/json.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { JsonValue } from './types.js';
2+
3+
/** Deep structural clone via JSON round-trip. Used to snapshot streamed messages. */
4+
export function clone<TValue>(value: TValue): TValue {
5+
return JSON.parse(JSON.stringify(value)) as TValue;
6+
}
7+
8+
/**
9+
* Parse JSON that may be truncated mid-stream. Returns `{}` for empty/garbage
10+
* input. Attempts a strict parse first, then closes any open strings/brackets
11+
* and retries — so partial tool-call arguments deserialize as they arrive.
12+
*/
13+
export function parsePartialJson(raw: string): Record<string, JsonValue | undefined> {
14+
const trimmed = raw.trim();
15+
if (!trimmed) {
16+
return {};
17+
}
18+
19+
try {
20+
return JSON.parse(trimmed) as Record<string, JsonValue | undefined>;
21+
} catch {
22+
// continue
23+
}
24+
25+
const completed = completePartialJson(trimmed);
26+
if (!completed) {
27+
return {};
28+
}
29+
30+
try {
31+
return JSON.parse(completed) as Record<string, JsonValue | undefined>;
32+
} catch {
33+
return {};
34+
}
35+
}
36+
37+
/** Close any open strings, objects, and arrays so a truncated fragment parses. */
38+
export function completePartialJson(input: string): string | undefined {
39+
let output = input;
40+
let inString = false;
41+
let escaping = false;
42+
const stack: string[] = [];
43+
44+
for (const char of input) {
45+
if (escaping) {
46+
escaping = false;
47+
continue;
48+
}
49+
50+
if (char === '\\') {
51+
escaping = true;
52+
continue;
53+
}
54+
55+
if (char === '"') {
56+
inString = !inString;
57+
continue;
58+
}
59+
60+
if (inString) {
61+
continue;
62+
}
63+
64+
if (char === '{') {
65+
stack.push('}');
66+
} else if (char === '[') {
67+
stack.push(']');
68+
} else if (char === '}' || char === ']') {
69+
stack.pop();
70+
}
71+
}
72+
73+
if (inString) {
74+
output += '"';
75+
}
76+
77+
while (stack.length > 0) {
78+
output += stack.pop();
79+
}
80+
81+
return output;
82+
}

0 commit comments

Comments
 (0)