blob: d1361f99341f019542b1296f9ce33c598c89e33c [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createHash } from 'node:crypto';
import { z } from 'zod';
export const READ_PAGE_MAX_CHARS = 7_500;
export const READ_DESCRIPTION =
'Read a text file, view a PNG/JPEG/GIF/WebP image, or read a Maka resource returned by a tool. Only path is required. Images are returned as images, without text pagination. Text reads return one bounded page by default; offset and limit select a line range within the response-size cap. If next is non-null, pass its complete object to Read to continue, including partial lines; otherwise the requested range is complete.';
export const readParameters = z.object({
path: z
.string({
error: 'path is required. Provide a file path or the Maka address returned by a tool.',
})
.min(1, 'path cannot be empty. Provide the location to read.')
.describe(
'File path relative to the session cwd, an absolute path, or a Maka address returned by a tool.',
),
offset: z
.number({ error: 'offset must be a non-negative integer. Omit it to start at the beginning.' })
.int('offset must be an integer. Omit it to start at the beginning.')
.nonnegative('offset must be non-negative. Omit it to start at the beginning.')
.optional()
.describe(
'Zero-based starting text line; defaults to 0. offset 200 starts at line 201. Omit for images or when using a continuation address.',
),
limit: z
.number({ error: 'limit must be a positive integer. Omit it to read one bounded page.' })
.int('limit must be an integer. Omit it to read one bounded page.')
.positive('limit must be positive. Omit it to read one bounded page.')
.optional()
.describe(
'Maximum text lines in the requested range. Omit for images or to browse one bounded page at a time. Use next to obtain any remaining content.',
),
});
export type ReadInput = z.infer<typeof readParameters>;
export const readPageSchema = z.object({
content: z.string(),
offset: z.number().int().nonnegative(),
returnedLines: z.number().int().nonnegative(),
totalLines: z.number().int().nonnegative(),
partialLine: z.literal(true).optional(),
next: readParameters.nullable(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
export type ReadPage = z.infer<typeof readPageSchema>;
export const readContinuationSchema = z
.object({
position: z.number().int().nonnegative(),
digest: z.string().regex(/^[a-f0-9]{32}$/),
})
.strict();
const CONTINUATION_PREFIX = 'maka://read/';
export function resolveReadInput(input: ReadInput): {
path: string;
position?: number;
digest?: string;
} {
if (!input.path.startsWith(CONTINUATION_PREFIX)) return { path: input.path };
try {
const url = new URL(input.path);
const path = Buffer.from(url.pathname.slice(1), 'base64url').toString('utf8');
const at = url.searchParams.get('at') ?? '';
const position = Number(at);
const digest = url.searchParams.get('sha') ?? '';
if (
!path ||
path.startsWith(CONTINUATION_PREFIX) ||
!/^\d+$/.test(at) ||
!Number.isSafeInteger(position) ||
!/^[a-f0-9]{32}$/.test(digest) ||
input.offset !== undefined
)
throw new Error();
return { path, position, digest };
} catch {
throw new Error(
'Invalid Read continuation. Copy the complete next object from the preceding result. To start again, use the original path and omit offset and limit.',
);
}
}
function digestText(content: string): string {
return createHash('sha256').update(content).digest('hex').slice(0, 32);
}
export function readPage(
content: string,
input: ReadInput,
maxChars = READ_PAGE_MAX_CHARS,
continuation?: z.infer<typeof readContinuationSchema>,
): ReadPage {
const resolved = continuation ? { path: input.path, ...continuation } : resolveReadInput(input);
if (resolved.digest && digestText(content) !== resolved.digest) {
throw new Error(
'The content changed since the previous page. This continuation cannot be used. Read the original path again to start from the current content.',
);
}
if (resolved.position !== undefined && resolved.position > content.length) {
throw new Error(
'Invalid Read continuation position. Copy the complete next object from the preceding result.',
);
}
const starts = [0];
for (let at = content.indexOf('\n'); at >= 0; at = content.indexOf('\n', at + 1))
starts.push(at + 1);
const totalLines = starts.length;
const lineAt = (position: number): number => {
let low = 0;
let high = starts.length - 1;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (starts[middle]! <= position) low = middle;
else high = middle - 1;
}
return low;
};
const offset = resolved.position === undefined ? (input.offset ?? 0) : lineAt(resolved.position);
const start = resolved.position ?? starts[Math.min(offset, totalLines)] ?? content.length;
const lastLine = Math.min(totalLines, offset + (input.limit ?? totalLines));
const end = lastLine < totalLines ? starts[lastLine]! - 1 : content.length;
let digest = resolved.digest;
const makePage = (stop: number): ReadPage => {
const complete = stop >= end;
const nextLine = Math.max(offset, lineAt(stop));
const boundary = stop === (starts[nextLine + 1] ?? content.length + 1) - 1;
const partialLine = !complete && !boundary;
const returnedLines =
offset >= totalLines
? 0
: complete
? lastLine - offset
: nextLine - offset + (partialLine ? 0 : 1);
let next: ReadInput | null = null;
if (!complete) {
const remaining = input.limit === undefined ? undefined : input.limit - returnedLines;
const position = partialLine ? stop : stop + 1;
next = {
path: `${CONTINUATION_PREFIX}${Buffer.from(resolved.path).toString('base64url')}?at=${position}&sha=${(digest ??= digestText(content))}`,
...(remaining === undefined ? {} : { limit: remaining }),
};
}
return {
content: content.slice(start, Math.max(start, stop)),
offset,
returnedLines,
totalLines,
...(partialLine || (offset < totalLines && start > starts[offset]!)
? { partialLine: true as const }
: {}),
next,
};
};
if (end - start <= maxChars) {
const page = makePage(end);
if (JSON.stringify(page).length <= maxChars) return page;
}
let low = start;
let high = Math.min(end, start + maxChars);
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (JSON.stringify(makePage(middle)).length <= maxChars) low = middle;
else high = middle - 1;
}
// Never split a UTF-16 surrogate pair between responses.
if (low > start && /[\uD800-\uDBFF]/.test(content[low - 1]!)) low--;
if (low <= start)
throw new Error(
'The Read address is too long to fit a bounded response. Use a shorter original path.',
);
const newline = content.lastIndexOf('\n', low);
if (newline > start) low = newline;
return makePage(low);
}
export function readableToolResult(serialized: string): string {
try {
const value: unknown = JSON.parse(serialized);
if (typeof value === 'string') return value;
if (
value &&
typeof value === 'object' &&
Object.keys(value).length === 2 &&
'kind' in value &&
value.kind === 'text' &&
'text' in value &&
typeof value.text === 'string'
)
return value.text;
if (
value &&
typeof value === 'object' &&
Object.keys(value).length === 1 &&
'content' in value &&
typeof value.content === 'string'
)
return value.content;
if (
value &&
typeof value === 'object' &&
'kind' in value &&
(value.kind === 'terminal' || value.kind === 'shell_run')
) {
const shell = value as Record<string, unknown>;
let output: string | undefined;
if (typeof shell.output === 'string') output = shell.output;
if (shell.output && typeof shell.output === 'object') {
const shellOutput = shell.output as Record<string, unknown>;
output = (
shellOutput.mode === 'pty'
? [shellOutput.scrollback, shellOutput.screen, shellOutput.lastAlternateScreen]
: [shellOutput.stdout, shellOutput.stderr]
)
.filter((part): part is string => typeof part === 'string' && part.length > 0)
.join('\n');
}
if (typeof shell.failureMessage === 'string' && shell.failureMessage.length > 0)
return output ? `${output}\n${shell.failureMessage}` : shell.failureMessage;
if (output !== undefined) return output;
}
} catch {
/* Plain text projections are already readable. */
}
return serialized;
}
export function readToolResultPage(
serialized: string,
input: ReadInput,
maxChars = READ_PAGE_MAX_CHARS,
): ReadPage {
let metadata: Record<string, unknown> | undefined;
try {
const value = JSON.parse(serialized);
if (value?.kind === 'terminal' || value?.kind === 'shell_run') {
metadata = Object.fromEntries(
Object.entries(value).filter(([key]) =>
['kind', 'status', 'exitCode', 'signal', 'revision', 'mode'].includes(key),
),
);
if (value.output && typeof value.output === 'object')
for (const key of ['stdoutTruncated', 'stderrTruncated', 'redacted']) {
if (typeof value.output[key] === 'boolean') metadata[key] = value.output[key];
}
}
} catch {
/* Text has no separate execution metadata. */
}
const page = readPage(
readableToolResult(serialized),
input,
maxChars - (metadata ? JSON.stringify(metadata).length + 16 : 0),
);
return metadata ? { ...page, metadata } : page;
}