@alisio/core 0.1.0-alpha.13 → 0.1.0-alpha.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,6 +116,10 @@ function clampMessage(message, maxToolResultChars, maxTextChars) {
116
116
  let used = 0;
117
117
  let changed = false;
118
118
  const content = message.result.content.map((part) => {
119
+ // Only the text projection is clipped: ui/image parts are display-only transcript data and
120
+ // are never truncated (raw bytes never reach the model, which only sees the projection).
121
+ if (part.type !== "text")
122
+ return part;
119
123
  const room = maxToolResultChars - used;
120
124
  const text = room <= 0
121
125
  ? TRUNCATION_MARKER
@@ -213,7 +217,8 @@ export function reduceMessagesToBudget(messages, targetChars, options = {}) {
213
217
  : m.role === "assistant"
214
218
  ? m.text.length > textCap
215
219
  : m.role === "tool" &&
216
- m.result.content.reduce((a, p) => a + p.text.length, 0) > toolCap;
220
+ m.result.content.reduce((a, p) => a + (p.type === "text" ? p.text.length : 0), 0) >
221
+ toolCap;
217
222
  if (over) {
218
223
  best = i;
219
224
  bestSize = current;
@@ -260,7 +265,12 @@ export function serializeForSummary(messages, maxItemChars = 4_000) {
260
265
  lines.push(`TOOL CALL ${c.id} ${c.name}: ${clip(c.arguments, maxItemChars)}`);
261
266
  }
262
267
  else {
263
- const text = m.result.content.map((c) => c.text).join("\n");
268
+ // Only the text projection is summarized: ui/image parts are display data and never
269
+ // reach the summarizer (raw image bytes in particular stay out of the checkpoint).
270
+ const text = m.result.content
271
+ .filter((p) => p.type === "text")
272
+ .map((c) => c.text)
273
+ .join("\n");
264
274
  lines.push(`TOOL RESULT ${m.callId}${m.result.isError ? " (error)" : ""}: ${clip(text, maxItemChars)}`);
265
275
  }
266
276
  }
@@ -66,6 +66,8 @@ export interface SessionStore {
66
66
  release(id: string): void;
67
67
  beginCall(session: string, call: ToolCall): void;
68
68
  endCall(session: string, call: ToolCall, result: ToolResult): void;
69
+ /** The persisted result of a completed tool call, for replay of rich (ui/image) parts. */
70
+ callResult(session: string, callId: string): ToolResult | undefined;
69
71
  reconcile(id: string, acknowledge?: boolean): void;
70
72
  event(id: string, runId: string, type: string, data: unknown): void;
71
73
  }
@@ -1,4 +1,4 @@
1
- import { textResult, } from "@alisio/sdk";
1
+ import { textProjection, textResult, } from "@alisio/sdk";
2
2
  import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, reduceMessagesToBudget, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
3
3
  /**
4
4
  * Default cumulative token budget for one run: several context windows (each turn re-sends the
@@ -409,9 +409,13 @@ export class AgentRunner {
409
409
  let completion;
410
410
  let truncated = false;
411
411
  let usage;
412
+ // Providers only ever see the text projection of tool results: ui/image parts are a
413
+ // display-only extension of the persisted transcript and never reach a model prompt
414
+ // (raw image bytes included). The store keeps the rich parts for TUI replay.
415
+ const providerMessages = messages.map((m) => m.role === "tool" ? { ...m, result: textProjection(m.result) } : m);
412
416
  for await (const e of provider.stream({
413
417
  instructions,
414
- messages,
418
+ messages: providerMessages,
415
419
  tools,
416
420
  maxOutputTokens: options.maxOutputTokens ?? o.maxOutputTokens ?? 4096,
417
421
  signal: combined,
@@ -498,7 +502,14 @@ export class AgentRunner {
498
502
  const { call } = p;
499
503
  let result;
500
504
  const started = Date.now();
501
- emit("tool_started", { name: call.name, id: call.id, arguments: call.arguments });
505
+ // `effect` rides along so the TUI can group consecutive calls by capability kind
506
+ // (read batches, write batches, commands…) without a name heuristic. Additive field.
507
+ emit("tool_started", {
508
+ name: call.name,
509
+ id: call.id,
510
+ arguments: call.arguments,
511
+ effect: p.tool?.effect ?? "external",
512
+ });
502
513
  try {
503
514
  combined.throwIfAborted();
504
515
  if (p.error)
@@ -572,6 +583,7 @@ export class AgentRunner {
572
583
  isError: result.isError ?? false,
573
584
  durationMs: Date.now() - started,
574
585
  preview: result.content
586
+ .filter((part) => part.type === "text")
575
587
  .map((c) => c.text)
576
588
  .join("\n")
577
589
  .slice(0, 2_000),
@@ -4,6 +4,7 @@ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/cli
4
4
  import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
5
5
  import { objectSchema } from "../tools/standard.js";
6
6
  import { loadVersion } from "../version.js";
7
+ import { mapMcpCallResult, mapMcpResourceResult } from "./rich.js";
7
8
  const TOOL_NAME_LIMIT = 64;
8
9
  /**
9
10
  * Per-server client close cap used ONLY by the terminal `close()`: the MCP SDK's stdio close can
@@ -111,7 +112,7 @@ export class McpConnector {
111
112
  const result = i.uri
112
113
  ? await client.readResource({ uri: String(i.uri) }, { signal: c.signal, timeout: 15000 })
113
114
  : await client.listResources(undefined, { signal: c.signal, timeout: 15000 });
114
- return textResult(JSON.stringify(result));
115
+ return mapMcpResourceResult(result);
115
116
  },
116
117
  }));
117
118
  undo.push(this.registry.register({
@@ -329,7 +330,7 @@ export class McpConnector {
329
330
  JSON.stringify(current.inputSchema) !== JSON.stringify(tool.inputSchema))
330
331
  throw new Error("MCP schema changed; reconnect the server to refresh tools");
331
332
  const result = await client.callTool({ name: tool.name, arguments: input }, { signal: ctx.signal, timeout: 30000 });
332
- return textResult(JSON.stringify(result), "isError" in result && result.isError === true);
333
+ return mapMcpCallResult(result);
333
334
  },
334
335
  }));
335
336
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * MCP → Alisio result mapping ("Nivel 0" rich tool results).
3
+ *
4
+ * MCP protocol types never leak into the SDK: this adapter is the single place that folds an
5
+ * MCP `CallToolResult` / `ReadResourceResult` into the Alisio-owned `ToolResult` content union
6
+ * (text, image, ui blocks). Mapping is heuristic and conservative:
7
+ *
8
+ * - `text` parts stay text parts; an image part becomes `{type:"image"}` (base64 data + mime).
9
+ * - `structuredContent` (or a JSON-parseable text part) is folded into a `ui` block when it
10
+ * matches a verified shape — `{columns,rows}` / `{headers,rows}` tables, all-scalar
11
+ * key-value objects, or `{nodes:[{label,children,meta}]}` trees. Anything else stays text
12
+ * (the previous flattening).
13
+ * - A canonical text projection of every ui block / image is ALWAYS appended as a text part so
14
+ * providers, compaction and headless output only see text.
15
+ * - Unknown call results (no contents, nothing recognized) keep the previous
16
+ * `JSON.stringify(result)` behavior verbatim.
17
+ */
18
+ import { type ToolResult, type UiBlock } from "@alisio/sdk";
19
+ /**
20
+ * Folds a candidate value into a UiBlock when it matches one of the verified shapes:
21
+ * `{columns,rows}` / `{headers,rows}` tables, `{nodes:[...]}` trees, or flat scalar objects.
22
+ */
23
+ export declare function blockFromUnknown(value: unknown): UiBlock | undefined;
24
+ /** Tries a JSON-parseable text part: recognized shapes become ui blocks, everything else stays text. */
25
+ export declare function blockFromJsonText(text: string): UiBlock | undefined;
26
+ /** Compact plain-text rendering of a ui block, used as the canonical model-visible projection. */
27
+ export declare function renderUiBlockText(block: UiBlock): string;
28
+ /**
29
+ * Maps an MCP `CallToolResult` into an Alisio `ToolResult`. Text/image parts map directly;
30
+ * verified table/key-value/tree shapes (from `structuredContent` or JSON text parts) become
31
+ * `ui` blocks; a canonical text projection is always appended when non-text parts exist, and
32
+ * unrecognized results keep the previous `JSON.stringify` flattening verbatim.
33
+ */
34
+ export declare function mapMcpCallResult(result: unknown): ToolResult;
35
+ /**
36
+ * Maps an MCP `ReadResourceResult` / `ListResourcesResult` into an Alisio `ToolResult`.
37
+ * Text contents map to text parts; image blobs become `{type:"image"}` parts; non-image blobs
38
+ * become a text marker (never raw base64 in the model prompt). Results without contents (a
39
+ * resource listing, for example) keep the previous `JSON.stringify` flattening.
40
+ */
41
+ export declare function mapMcpResourceResult(result: unknown): ToolResult;
@@ -0,0 +1,290 @@
1
+ /**
2
+ * MCP → Alisio result mapping ("Nivel 0" rich tool results).
3
+ *
4
+ * MCP protocol types never leak into the SDK: this adapter is the single place that folds an
5
+ * MCP `CallToolResult` / `ReadResourceResult` into the Alisio-owned `ToolResult` content union
6
+ * (text, image, ui blocks). Mapping is heuristic and conservative:
7
+ *
8
+ * - `text` parts stay text parts; an image part becomes `{type:"image"}` (base64 data + mime).
9
+ * - `structuredContent` (or a JSON-parseable text part) is folded into a `ui` block when it
10
+ * matches a verified shape — `{columns,rows}` / `{headers,rows}` tables, all-scalar
11
+ * key-value objects, or `{nodes:[{label,children,meta}]}` trees. Anything else stays text
12
+ * (the previous flattening).
13
+ * - A canonical text projection of every ui block / image is ALWAYS appended as a text part so
14
+ * providers, compaction and headless output only see text.
15
+ * - Unknown call results (no contents, nothing recognized) keep the previous
16
+ * `JSON.stringify(result)` behavior verbatim.
17
+ */
18
+ import { textResult } from "@alisio/sdk";
19
+ const scalar = (value) => typeof value === "string"
20
+ ? value
21
+ : typeof value === "number" || typeof value === "boolean" || value === null
22
+ ? String(value)
23
+ : undefined;
24
+ /** Cell rendering: scalars verbatim, nested values compact-JSON'd (never raw bytes). */
25
+ const cellText = (value) => typeof value === "string"
26
+ ? value
27
+ : value === null || value === undefined
28
+ ? ""
29
+ : typeof value === "object"
30
+ ? JSON.stringify(value)
31
+ : String(value);
32
+ function tableShape(value) {
33
+ if (!value || typeof value !== "object" || Array.isArray(value))
34
+ return undefined;
35
+ const record = value;
36
+ const columnsRaw = Array.isArray(record.columns)
37
+ ? record.columns
38
+ : Array.isArray(record.headers)
39
+ ? record.headers
40
+ : undefined;
41
+ const rowsRaw = Array.isArray(record.rows) ? record.rows : undefined;
42
+ if (!columnsRaw || !rowsRaw)
43
+ return undefined;
44
+ const columns = [];
45
+ for (const column of columnsRaw) {
46
+ const text = scalar(column);
47
+ if (text === undefined)
48
+ return undefined;
49
+ columns.push(text);
50
+ }
51
+ const rows = [];
52
+ for (const row of rowsRaw) {
53
+ if (!row || typeof row !== "object")
54
+ return undefined;
55
+ if (Array.isArray(row)) {
56
+ rows.push(row.map(cellText));
57
+ continue;
58
+ }
59
+ // Object rows are keyed by the declared columns (`{columns, rows:[{a:1,b:2}]}`).
60
+ const named = row;
61
+ rows.push(columns.map((column) => cellText(named[column])));
62
+ }
63
+ if (!columns.length || !rows.length)
64
+ return undefined;
65
+ return { kind: "table", columns, rows };
66
+ }
67
+ function treeNode(value) {
68
+ if (!value || typeof value !== "object" || Array.isArray(value))
69
+ return undefined;
70
+ const record = value;
71
+ const label = scalar(record.label) ?? scalar(record.name);
72
+ if (label === undefined)
73
+ return undefined;
74
+ const meta = scalar(record.meta) ?? scalar(record.value) ?? scalar(record.detail);
75
+ const node = { label };
76
+ if (meta !== undefined && meta !== label)
77
+ node.meta = meta;
78
+ const children = Array.isArray(record.children) ? record.children : undefined;
79
+ if (children) {
80
+ const mapped = [];
81
+ for (const child of children) {
82
+ const next = treeNode(child);
83
+ if (!next)
84
+ return undefined;
85
+ mapped.push(next);
86
+ }
87
+ node.children = mapped;
88
+ }
89
+ return node;
90
+ }
91
+ function treeShape(value) {
92
+ if (!value || typeof value !== "object" || Array.isArray(value))
93
+ return undefined;
94
+ const record = value;
95
+ const nodes = Array.isArray(record.nodes)
96
+ ? record.nodes
97
+ : record.tree !== undefined && typeof record.tree === "object" && !Array.isArray(record.tree)
98
+ ? [record.tree]
99
+ : undefined;
100
+ if (!nodes)
101
+ return undefined;
102
+ const mapped = [];
103
+ for (const node of nodes) {
104
+ const next = treeNode(node);
105
+ if (!next)
106
+ return undefined;
107
+ mapped.push(next);
108
+ }
109
+ return mapped.length ? { kind: "tree", nodes: mapped } : undefined;
110
+ }
111
+ function keyValueShape(value) {
112
+ if (!value || typeof value !== "object" || Array.isArray(value))
113
+ return undefined;
114
+ const record = value;
115
+ const entries = Object.entries(record);
116
+ if (!entries.length || entries.some(([, v]) => typeof v === "object" && v !== null))
117
+ return undefined;
118
+ return { kind: "key-value", entries: entries.map(([key, v]) => [key, String(v)]) };
119
+ }
120
+ /**
121
+ * Folds a candidate value into a UiBlock when it matches one of the verified shapes:
122
+ * `{columns,rows}` / `{headers,rows}` tables, `{nodes:[...]}` trees, or flat scalar objects.
123
+ */
124
+ export function blockFromUnknown(value) {
125
+ return tableShape(value) ?? treeShape(value) ?? keyValueShape(value);
126
+ }
127
+ /** Tries a JSON-parseable text part: recognized shapes become ui blocks, everything else stays text. */
128
+ export function blockFromJsonText(text) {
129
+ const trimmed = text.trim();
130
+ if (!trimmed.startsWith("{"))
131
+ return undefined;
132
+ try {
133
+ return blockFromUnknown(JSON.parse(trimmed));
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ }
139
+ /** Compact plain-text rendering of a ui block, used as the canonical model-visible projection. */
140
+ export function renderUiBlockText(block) {
141
+ switch (block.kind) {
142
+ case "table": {
143
+ const head = `| ${block.columns.join(" | ")} |`;
144
+ const sep = `| ${block.columns.map(() => "---").join(" | ")} |`;
145
+ const rows = block.rows.map((row) => `| ${row.join(" | ")} |`);
146
+ return [block.caption ? `[table: ${block.caption}]` : "", head, sep, ...rows]
147
+ .filter((line) => line !== "")
148
+ .join("\n");
149
+ }
150
+ case "key-value":
151
+ return [
152
+ block.caption ? `[key-value: ${block.caption}]` : "",
153
+ ...block.entries.map(([key, value]) => `${key}: ${value}`),
154
+ ]
155
+ .filter((line) => line !== "")
156
+ .join("\n");
157
+ case "tree": {
158
+ const lines = [];
159
+ const walk = (nodes, prefix) => {
160
+ nodes.forEach((node, index) => {
161
+ const last = index === nodes.length - 1;
162
+ lines.push(`${prefix}${last ? "└─ " : "├─ "}${node.label}${node.meta ? ` (${node.meta})` : ""}`);
163
+ if (node.children?.length)
164
+ walk(node.children, `${prefix}${last ? " " : "│ "}`);
165
+ });
166
+ };
167
+ walk(block.nodes, "");
168
+ return lines.join("\n");
169
+ }
170
+ case "code": {
171
+ const fence = "```";
172
+ const lang = block.lang ? `${block.lang}\n` : "\n";
173
+ const body = block.code.endsWith("\n") ? block.code : `${block.code}\n`;
174
+ return [block.caption ? `[code: ${block.caption}]` : "", `${fence}${lang}${body}${fence}`]
175
+ .filter((line) => line !== "")
176
+ .join("\n");
177
+ }
178
+ case "markdown":
179
+ return block.text;
180
+ }
181
+ }
182
+ const base64Bytes = (data) => Math.floor((data.length * 3) / 4);
183
+ const projectionText = (parts) => parts
184
+ .map((part) => part.type === "ui"
185
+ ? renderUiBlockText(part.block)
186
+ : `[image: ${part.mimeType} (${base64Bytes(part.data)} bytes)]`)
187
+ .join("\n");
188
+ /**
189
+ * Maps an MCP `CallToolResult` into an Alisio `ToolResult`. Text/image parts map directly;
190
+ * verified table/key-value/tree shapes (from `structuredContent` or JSON text parts) become
191
+ * `ui` blocks; a canonical text projection is always appended when non-text parts exist, and
192
+ * unrecognized results keep the previous `JSON.stringify` flattening verbatim.
193
+ */
194
+ export function mapMcpCallResult(result) {
195
+ const raw = (result ?? {});
196
+ const parts = Array.isArray(raw.content) ? raw.content : [];
197
+ if (!parts.length && raw.structuredContent === undefined)
198
+ return textResult(JSON.stringify(result), raw.isError === true);
199
+ const content = [];
200
+ const rich = [];
201
+ const keepSourceOrder = (part) => {
202
+ rich.push(part);
203
+ content.push(part);
204
+ };
205
+ for (const part of parts) {
206
+ if (!part || typeof part !== "object")
207
+ continue;
208
+ const record = part;
209
+ const type = record.type;
210
+ if (type === "text") {
211
+ const text = typeof record.text === "string" ? record.text : JSON.stringify(record.text ?? "");
212
+ const block = blockFromJsonText(text);
213
+ if (block)
214
+ keepSourceOrder({ type: "ui", block });
215
+ else
216
+ content.push({ type: "text", text });
217
+ }
218
+ else if (type === "image") {
219
+ const data = typeof record.data === "string" ? record.data : "";
220
+ if (data)
221
+ keepSourceOrder({
222
+ type: "image",
223
+ mimeType: typeof record.mimeType === "string" ? record.mimeType : "application/octet-stream",
224
+ data,
225
+ });
226
+ }
227
+ else {
228
+ // Audio / embedded resource / unknown part: never forward raw bytes; a marker keeps the model informed.
229
+ content.push({ type: "text", text: `[mcp part: ${type}]` });
230
+ }
231
+ }
232
+ const structured = blockFromUnknown(raw.structuredContent);
233
+ if (structured)
234
+ keepSourceOrder({ type: "ui", block: structured });
235
+ if (rich.length)
236
+ content.push({ type: "text", text: projectionText(rich) });
237
+ if (!content.length)
238
+ return textResult(JSON.stringify(result), raw.isError === true);
239
+ return { content, ...(raw.isError === true ? { isError: true } : {}) };
240
+ }
241
+ /**
242
+ * Maps an MCP `ReadResourceResult` / `ListResourcesResult` into an Alisio `ToolResult`.
243
+ * Text contents map to text parts; image blobs become `{type:"image"}` parts; non-image blobs
244
+ * become a text marker (never raw base64 in the model prompt). Results without contents (a
245
+ * resource listing, for example) keep the previous `JSON.stringify` flattening.
246
+ */
247
+ export function mapMcpResourceResult(result) {
248
+ const raw = (result ?? {});
249
+ const contents = Array.isArray(raw.contents) ? raw.contents : [];
250
+ if (!contents.length)
251
+ return textResult(JSON.stringify(result), typeof result === "object" &&
252
+ result !== null &&
253
+ result.isError === true);
254
+ const content = [];
255
+ const rich = [];
256
+ for (const item of contents) {
257
+ if (!item || typeof item !== "object")
258
+ continue;
259
+ const record = item;
260
+ const mimeType = typeof record.mimeType === "string" ? record.mimeType : "application/octet-stream";
261
+ if (typeof record.text === "string") {
262
+ const block = blockFromJsonText(record.text);
263
+ if (block) {
264
+ rich.push({ type: "ui", block });
265
+ content.push({ type: "ui", block });
266
+ }
267
+ else
268
+ content.push({ type: "text", text: record.text });
269
+ }
270
+ else if (typeof record.blob === "string") {
271
+ if (mimeType.startsWith("image/")) {
272
+ const image = { type: "image", mimeType, data: record.blob };
273
+ rich.push(image);
274
+ content.push(image);
275
+ }
276
+ else
277
+ content.push({
278
+ type: "text",
279
+ text: `[resource blob: ${mimeType} (${base64Bytes(record.blob)} bytes)]`,
280
+ });
281
+ }
282
+ }
283
+ if (rich.length)
284
+ content.push({ type: "text", text: projectionText(rich) });
285
+ if (!content.length)
286
+ return textResult(JSON.stringify(result), typeof result === "object" &&
287
+ result !== null &&
288
+ result.isError === true);
289
+ return { content };
290
+ }
@@ -30,6 +30,8 @@ export declare class SQLiteStore implements SessionStore {
30
30
  release(id: string): void;
31
31
  beginCall(id: string, call: ToolCall): void;
32
32
  endCall(id: string, call: ToolCall, result: ToolResult): void;
33
+ /** Rich (ui/image) parts persist as part of the serialized result and replay verbatim. */
34
+ callResult(id: string, callId: string): ToolResult | undefined;
33
35
  reconcile(id: string, acknowledge?: boolean): void;
34
36
  event(id: string, runId: string, type: string, data: unknown): void;
35
37
  getState(plugin: string, key: string): unknown;
@@ -211,6 +211,13 @@ export class SQLiteStore {
211
211
  .prepare("INSERT INTO tool_calls(session,call_id,status,result) VALUES(?,?,'completed',?) ON CONFLICT(session,call_id) DO UPDATE SET status='completed',result=excluded.result")
212
212
  .run(id, call.id, JSON.stringify(result));
213
213
  }
214
+ /** Rich (ui/image) parts persist as part of the serialized result and replay verbatim. */
215
+ callResult(id, callId) {
216
+ const row = this.db
217
+ .prepare("SELECT result FROM tool_calls WHERE session=? AND call_id=? AND status='completed'")
218
+ .get(id, callId);
219
+ return row?.result ? JSON.parse(row.result) : undefined;
220
+ }
214
221
  reconcile(id, acknowledge = false) {
215
222
  this.db.transaction(() => {
216
223
  const messages = this.messages(id);
@@ -54,7 +54,10 @@ export async function runExecute(code, deps) {
54
54
  ...(deps.session ? { session: deps.session } : {}),
55
55
  ...(deps.label ? { label: deps.label } : {}),
56
56
  });
57
- const text = result.content.map((c) => c.text).join("\n");
57
+ const text = result.content
58
+ .filter((p) => p.type === "text")
59
+ .map((c) => c.text)
60
+ .join("\n");
58
61
  if (result.isError)
59
62
  throw new Error(text);
60
63
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/core",
3
- "version": "0.1.0-alpha.13",
3
+ "version": "0.1.0-alpha.14",
4
4
  "description": "Embeddable provider-agnostic Alisio agent core: tool loop, context compaction, local tools, MCP, plugin host and SQLite session store. Runs on Node >=22.16 and Bun.",
5
5
  "author": "Gustavo Gutiérrez",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "node": ">=22.16"
42
42
  },
43
43
  "dependencies": {
44
- "@alisio/sdk": "0.1.0-alpha.8",
44
+ "@alisio/sdk": "0.1.0-alpha.9",
45
45
  "@mixmark-io/domino": "2.2.0",
46
46
  "@modelcontextprotocol/client": "2.1.0",
47
47
  "ajv": "8.20.0",