mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
A tool call's `output` is set when the tool RETURNS, so running code — the
most expensive thing this product does — was also the most silent: a spinner,
then a wall of text. Cloud now appends a command's output to a session log as
the program produces it, and this is the half that asks for it.
The chain, and each link is the smallest thing that could work:
- `POST /v1/sandboxes/run` instead of `/v1/sandboxes/:id/exec`. The same
command through the same code path, and exactly one of them carries a
session to narrate into — `/:id/exec` has no field for one. It is also the
op the fleet publishes to agents as `run_in_sandbox`, so a person driving
a sandbox and an agent driving one share one address.
- `services/Tools/watch.js` opens the session and says where it is, on an
`on_run_step_delta` addressed at the step the browser is already drawing
— the channel the OAuth prompt already uses to attach state to one call
mid-run. It is INJECTED into the tool the way web search's callback is,
so `packages/api` still has no Express in it. Every failure answers an
empty handle: watching is how a run is READ, never how it is run.
- `/v1/chat/runs/{stream,stop}` relays both, resolving the credential
through `currentBearer` like `/v1/chat/ask`. No guests — a session and a
sandbox belong to the org that leased them, and the shared guest key
carries no tenant.
- `useRunLog` tails the feed and `ExecuteCode` renders it until the real
output lands, so the finished result replaces the narration instead of
appearing under a second copy of it.
Stop ends the WORK. The sandbox stays leased, so the checkout, the files and
the log are all still there afterwards — and the control says so, because a
button that might delete your work is one people hesitate over instead of
using.
Threading `run` through `useStepHandler` keeps it across deltas rather than
assigning from each one: `auth` is cleared by omission (that is how a prompt
comes down), but the ids are sent once, before the command starts, so reading
them the same way would drop them on the next frame and close the tail
mid-command.
Proven in a browser against a real backend and the real store
(e2e/specs/live-run.spec.ts): each line asserted BEFORE the next is sent, so
every one is a moment when the tool has not returned; Stop offered, clicked,
and shown acting on the sandbox.
173 lines
6.3 KiB
JavaScript
173 lines
6.3 KiB
JavaScript
const { StepTypes, GraphEvents } = require('@hanzochat/agents');
|
|
const { logger } = require('@hanzochat/data-schemas');
|
|
const { sendEvent, GenerationJobManager } = require('@hanzochat/api');
|
|
|
|
/**
|
|
* Make a tool's run watchable.
|
|
*
|
|
* A code-execution call leases a sandbox and runs a command in it. The command
|
|
* can take minutes — an install, a test suite, a build — and until now the
|
|
* browser learned nothing until it was over: the tool call's `output` is set by
|
|
* `on_run_step_completed` and by nothing before it. So the product's most
|
|
* expensive act was also its most silent one.
|
|
*
|
|
* Two things have to happen before the command starts, and they are one act:
|
|
*
|
|
* 1. OPEN A SESSION. Cloud's agent registry is where a run's narration is
|
|
* kept — a durable ordered log, served live. A command names the session it
|
|
* narrates into and the sandbox appends its output there as the program
|
|
* produces it. No session, no narration; the session has to exist first.
|
|
* 2. SAY WHERE IT IS. The browser has the tool call already (the step is
|
|
* dispatched from the model stream, before the tool is even entered) but it
|
|
* cannot guess the two ids. They ride an `on_run_step_delta` addressed at
|
|
* that exact step — the same channel, and the same shape, the OAuth prompt
|
|
* already uses to attach `auth` to a specific tool call mid-run.
|
|
*
|
|
* WHY THIS IS INJECTED. The tool lives in `packages/api`, which has no Express
|
|
* and no business acquiring one; `res`, the resumable stream and the registry
|
|
* all live here. So the server builds this and hands it in, exactly as it
|
|
* already does for web search's `createOnSearchResults`. The tool calls one
|
|
* function and knows nothing about how a browser is reached.
|
|
*
|
|
* BEST-EFFORT BY CONSTRUCTION. Watching is how a run is READ, never how it is
|
|
* run. Every failure here answers an empty handle, the command runs unwatched,
|
|
* and the result arrives exactly as it did before. A registry that is down must
|
|
* never cost somebody their code.
|
|
*/
|
|
|
|
/** Cloud, resolved the one way the sandbox client resolves it. */
|
|
function cloudBaseUrl(baseUrl) {
|
|
return baseUrl.replace(/\/+$/, '');
|
|
}
|
|
|
|
/**
|
|
* Register the run and answer its session id, or `''`.
|
|
*
|
|
* `host` is the sandbox, so the fleet views show a code-execution run as the
|
|
* sandbox run it is rather than as an anonymous stream from a browser tab.
|
|
*/
|
|
async function open({ baseUrl, token, org, sandbox, title }) {
|
|
try {
|
|
const res = await fetch(`${cloudBaseUrl(baseUrl)}/agents/sessions`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
...(org ? { 'X-Org-Id': org } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
agent: 'hanzo-chat',
|
|
title: (title ?? '').slice(0, 120),
|
|
status: 'running',
|
|
host: sandbox,
|
|
}),
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
if (!res.ok) {
|
|
await res.body?.cancel().catch(() => {});
|
|
return '';
|
|
}
|
|
const body = await res.json();
|
|
return typeof body?.id === 'string' ? body.id : '';
|
|
} catch (err) {
|
|
logger.debug('[watch] could not open a session; the run goes unwatched', err);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tell the browser where to watch, on the step it is already rendering.
|
|
*
|
|
* `args: ''` is deliberate and load-bearing: the client CONCATENATES a delta's
|
|
* args onto what it has, so anything else here would corrupt the command the
|
|
* model streamed. The OAuth emitter this copies does the same for the same
|
|
* reason.
|
|
*/
|
|
function say({ res, streamId, stepId, toolCall, run }) {
|
|
const data = {
|
|
id: stepId,
|
|
delta: {
|
|
type: StepTypes.TOOL_CALLS,
|
|
tool_calls: [{ ...toolCall, args: '' }],
|
|
run,
|
|
},
|
|
};
|
|
const event = { event: GraphEvents.ON_RUN_STEP_DELTA, data };
|
|
if (streamId) {
|
|
return GenerationJobManager.emitChunk(streamId, event);
|
|
}
|
|
if (res) {
|
|
sendEvent(res, event);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Build the "make this watchable" function a tool calls once, with the sandbox
|
|
* it just leased. Answers the session to narrate into and the way to close it —
|
|
* one handle, because opening a run and finishing it are the same concern and
|
|
* splitting them across two injected functions is two things to forget.
|
|
*
|
|
* @param {object} deps
|
|
* @param {import('http').ServerResponse} [deps.res]
|
|
* @param {string|null} [deps.streamId]
|
|
* @param {string} deps.baseUrl cloud `/v1` base — the sandbox client's own
|
|
* @param {string} deps.token the caller's IAM bearer; there is no server key
|
|
* @param {string} [deps.org]
|
|
* @returns {(sandbox: string, toolCall: object|undefined, title?: string)
|
|
* => Promise<{ session: string, done: (status: string) => Promise<void> }>}
|
|
*/
|
|
function createWatch({ res, streamId = null, baseUrl, token, org }) {
|
|
return async function watch(sandbox, toolCall, title) {
|
|
const nothing = { session: '', done: async () => {} };
|
|
if (!sandbox || !token) {
|
|
return nothing;
|
|
}
|
|
const session = await open({ baseUrl, token, org, sandbox, title });
|
|
if (!session) {
|
|
return nothing;
|
|
}
|
|
// The ids reach the browser BEFORE the command starts, because a person
|
|
// watching needs them while there is still something to watch.
|
|
const { args: _args, stepId, ...rest } = toolCall ?? {};
|
|
if (stepId) {
|
|
try {
|
|
await say({ res, streamId, stepId, toolCall: rest, run: { session, sandbox } });
|
|
} catch (err) {
|
|
// The log is still being written; only this browser missed the address.
|
|
logger.debug('[watch] could not announce the run', err);
|
|
}
|
|
}
|
|
return {
|
|
session,
|
|
done: (status) => close({ baseUrl, token, org, session, status }),
|
|
};
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Mark the run finished. The registry keeps a run's last word; a row left
|
|
* `running` forever is a fleet view that lies.
|
|
*/
|
|
async function close({ baseUrl, token, org, session, status }) {
|
|
if (!session) {
|
|
return;
|
|
}
|
|
try {
|
|
await fetch(`${cloudBaseUrl(baseUrl)}/agents/sessions/${encodeURIComponent(session)}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
...(org ? { 'X-Org-Id': org } : {}),
|
|
},
|
|
body: JSON.stringify({ status }),
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
} catch (err) {
|
|
logger.debug('[watch] could not close the session', err);
|
|
}
|
|
}
|
|
|
|
module.exports = { createWatch };
|