From 595fbfc31dd74bb114888bbee2e57acb19b32210 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Sun, 26 Apr 2026 19:57:50 +0200 Subject: [PATCH] feat(core): ContentPart type + parts() helper for multi-format result envelopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formalizes the convention surfaced during the admin-agents UX walk: the final result event can carry multiple MIME-typed views of the same output. Order in the array signals render preference. Backward compatible — `result(success, output?: unknown)` still accepts any value. API additions (no signature changes): - `ContentPart` type — `{ type: string; content: unknown }` - `ResultOutput` type — alias for `unknown` with docs - `parts({ markdown, text, json, extra })` factory — builds a ContentPart[] in canonical preference order Canonical types: `text/markdown` → `text/plain` → `application/json`. Anything else passes through `extra`. Cross-repo follow-ups: - web/dashboard ResultEvent: detect array-of-parts, render preferred view + JSON toggle (in flight: agentage/web#235) - agents: emit `result(true, parts({ markdown, json }))` on the 5 TS agents (in flight: agentage/agents#5) Bumps @agentage/core to 0.10.0 (minor — additive). --- package-lock.json | 22 ++++++------ packages/core/package.json | 2 +- packages/core/src/events.test.ts | 61 +++++++++++++++++++++++++++++++- packages/core/src/events.ts | 38 ++++++++++++++++++-- packages/core/src/index.ts | 4 ++- packages/core/src/types.ts | 34 +++++++++++++++++- 6 files changed, 143 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5cff78e..16c5f0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,17 +12,17 @@ "packages/*" ], "devDependencies": { - "@anthropic-ai/sdk": "*", - "@types/node": "*", - "@typescript-eslint/eslint-plugin": "*", - "@typescript-eslint/parser": "*", - "@vitest/coverage-v8": "*", - "eslint": "*", - "eslint-config-prettier": "*", - "eslint-plugin-prettier": "*", + "@anthropic-ai/sdk": "latest", + "@types/node": "latest", + "@typescript-eslint/eslint-plugin": "latest", + "@typescript-eslint/parser": "latest", + "@vitest/coverage-v8": "latest", + "eslint": "latest", + "eslint-config-prettier": "latest", + "eslint-plugin-prettier": "latest", "prettier": "latest", - "typescript": "*", - "vitest": "*", + "typescript": "latest", + "vitest": "latest", "zod": "4.3.6", "zod-to-json-schema": "3.25.2" }, @@ -2850,7 +2850,7 @@ }, "packages/core": { "name": "@agentage/core", - "version": "0.9.0", + "version": "0.10.0", "license": "MIT", "engines": { "node": ">=22.0.0", diff --git a/packages/core/package.json b/packages/core/package.json index e18298d..3a49a3e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@agentage/core", - "version": "0.9.0", + "version": "0.10.0", "description": "Agent interface and run model for the Agentage platform", "type": "module", "main": "./dist/index.js", diff --git a/packages/core/src/events.test.ts b/packages/core/src/events.test.ts index 3a770af..f975855 100644 --- a/packages/core/src/events.test.ts +++ b/packages/core/src/events.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { output, progress, error, result } from './events.js'; +import { output, progress, error, result, parts } from './events.js'; describe('output', () => { it('creates text output event with default format', () => { @@ -91,4 +91,63 @@ describe('result', () => { expect(event.timestamp).toBeGreaterThanOrEqual(before); expect(event.timestamp).toBeLessThanOrEqual(Date.now()); }); + + it('accepts a ContentPart array as output', () => { + const event = result(true, [ + { type: 'text/markdown', content: '# Done' }, + { type: 'application/json', content: { ok: true } }, + ]); + expect(event.data).toEqual({ + type: 'result', + success: true, + output: [ + { type: 'text/markdown', content: '# Done' }, + { type: 'application/json', content: { ok: true } }, + ], + }); + }); +}); + +describe('parts', () => { + it('orders markdown → text → json', () => { + expect(parts({ markdown: '# m', text: 't', json: { j: 1 } })).toEqual([ + { type: 'text/markdown', content: '# m' }, + { type: 'text/plain', content: 't' }, + { type: 'application/json', content: { j: 1 } }, + ]); + }); + + it('omits absent slots', () => { + expect(parts({ markdown: '# m' })).toEqual([{ type: 'text/markdown', content: '# m' }]); + expect(parts({ json: { x: 1 } })).toEqual([{ type: 'application/json', content: { x: 1 } }]); + }); + + it('keeps json: null distinct from absent (null is valid JSON)', () => { + expect(parts({ json: null })).toEqual([{ type: 'application/json', content: null }]); + expect(parts({})).toEqual([]); + }); + + it('appends extra parts after canonical ones', () => { + expect( + parts({ + markdown: '# m', + extra: [{ type: 'text/csv', content: 'a,b\n1,2' }], + }) + ).toEqual([ + { type: 'text/markdown', content: '# m' }, + { type: 'text/csv', content: 'a,b\n1,2' }, + ]); + }); + + it('feeds straight into result()', () => { + const event = result(true, parts({ markdown: '# Done', json: { ok: true } })); + expect(event.data).toEqual({ + type: 'result', + success: true, + output: [ + { type: 'text/markdown', content: '# Done' }, + { type: 'application/json', content: { ok: true } }, + ], + }); + }); }); diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 0a3d1ef..351fee6 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1,4 +1,4 @@ -import type { RunEvent } from './types.js'; +import type { ContentPart, ResultOutput, RunEvent } from './types.js'; /** Create a text output event */ export const output = (content: unknown, format = 'text'): RunEvent => ({ @@ -21,9 +21,41 @@ export const error = (code: string, message: string, recoverable = false): RunEv timestamp: Date.now(), }); -/** Create a result event */ -export const result = (success: boolean, output?: unknown): RunEvent => ({ +/** + * Create a result event. + * + * `output` may be: + * - A single value (legacy / programmatic shape). + * - An array of `ContentPart` for multi-format results — order signals + * preference. Build via `parts({ markdown, json, text })` or pass the + * array literally. + * + * @example + * yield result(true, parts({ + * markdown: '# Done\n\n42 commits', + * json: { commits: 42 }, + * })); + */ +export const result = (success: boolean, output?: ResultOutput): RunEvent => ({ type: 'result', data: { type: 'result', success, output }, timestamp: Date.now(), }); + +/** + * Build a `ContentPart[]` for `result()`. Order signals render preference: + * markdown → text → json. Pass through `extra` for non-canonical MIME parts. + */ +export const parts = (input: { + markdown?: string; + text?: string; + json?: unknown; + extra?: ContentPart[]; +}): ContentPart[] => { + const list: ContentPart[] = []; + if (input.markdown != null) list.push({ type: 'text/markdown', content: input.markdown }); + if (input.text != null) list.push({ type: 'text/plain', content: input.text }); + if (input.json !== undefined) list.push({ type: 'application/json', content: input.json }); + if (input.extra) list.push(...input.extra); + return list; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 91bcbde..c6faefb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,7 @@ export type { AgentFactory, AgentRuntime, AgentRegistry, + ContentPart, CtxRunFn, CtxRunResult, RunState, @@ -18,6 +19,7 @@ export type { RunEventType, RunEventData, RunEvent, + ResultOutput, Tool, McpServer, } from './types.js'; @@ -36,7 +38,7 @@ export { sequence, parallel, map } from './combinators/index.js'; export type { StepRef, MapFactory } from './combinators/index.js'; // Event helpers -export { output, progress, error, result } from './events.js'; +export { output, progress, error, result, parts } from './events.js'; // Builders export { tool } from './tool.js'; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 98f5684..aafdf7b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -226,6 +226,38 @@ export interface Run { */ export type RunEventType = 'output' | 'state' | 'error' | 'input_required' | 'result'; +/** + * One MIME-typed slice of a multi-part result. Order in the array signals + * preference — first match by canonical type wins on render. + * + * Canonical types: + * - `text/markdown` — user-facing rendered view + * - `application/json` — structured data for programmatic consumers + * - `text/plain` — raw text fallback + * + * @example + * yield result(true, [ + * { type: 'text/markdown', content: '# Done\\n\\n42 commits' }, + * { type: 'application/json', content: { commits: 42 } }, + * ]); + */ +export interface ContentPart { + type: string; + content: unknown; +} + +/** + * Result output may be: + * - A single value (legacy / programmatic shape) + * - An array of `ContentPart` (multi-format envelope) + * + * The dashboard prefers `text/markdown`, falls back to `application/json`, + * then `text/plain`, then the first part. Children consuming a parent's + * `result.output` (orchestrators, ctx.run callers) should accept either + * shape. + */ +export type ResultOutput = unknown; + /** * Event payloads — discriminated union. * @@ -237,7 +269,7 @@ export type RunEventData = | { type: 'state'; state: RunState; message?: string } | { type: 'error'; code: string; message: string; recoverable: boolean } | { type: 'input_required'; prompt: string; schema?: JsonSchema } - | { type: 'result'; success: boolean; output?: unknown }; + | { type: 'result'; success: boolean; output?: ResultOutput }; /** A single event in the run stream */ export interface RunEvent {