@istar-ts/agent
v0.17.3
Published
A framework-free tool-calling loop between an OpenAI-compatible chat model and istar-ts tools (MCP or @istar-ts/actions), with propose/auto approval and a key-free transcript.
Maintainers
Readme
@istar-ts/agent
A framework-free tool-calling loop between an OpenAI-compatible chat model and istar-ts tools (#126 D3). No React, no GLSP: the same code runs in a browser drawer, headless in an evaluation runner, or in another app such as goal-controller.
import { createActionDispatcher } from '@istar-ts/actions';
import { createModelStore } from '@istar-ts/core';
import { DEEPSEEK, createActionChangeSet, createChatClient, runAgent } from '@istar-ts/agent';
const store = createModelStore(model);
const dispatcher = createActionDispatcher(store, { guards });
const changeSet = createActionChangeSet({ dispatcher, getModel: store.getModel, guards });
const client = createChatClient({ ...DEEPSEEK, apiKey }); // the key stays inside the client
const { transcript, changeSet: proposed } = await runAgent({
client,
prompt: 'Give the model an auditor that reviews requests.',
changeSet, // Propose mode, the default
maxSteps: 20,
budget: { maxTokens: 200_000 },
signal: controller.signal,
});
// Review `proposed` (each entry: the call, its args, its dry-run result), then:
await changeSet.apply(proposed.filter(keep)); // one `compound`: one undo stepTools
A ToolRegistry lists ToolSpecs (OpenAI's function object: name, description, a JSON
Schema parameters, and readOnly) and calls them, answering a ToolResult in
ActionResult's shape: { ok: true, content } or { ok: false, diagnostics }, where the
diagnostics are GoalDiagnostics (code, message, elementId), so the model can repair against a
refusal. Two backends:
A, MCP —
createMcpToolRegistry(client)over an MCP client'stools/list/tools/call(in istar-ts,@eclipse-glsp/server-mcp's diagram tools).McpToolClientis typed structurally; a connected@modelcontextprotocol/sdkClientis one.isErrorresults become refusals (a result carrying{ diagnostics }, structured or as a JSON text block, keeps them). A tool is read-only by itsreadOnlyHintwhen the server gives one, else by name (query-*,count-*,diagram-model,element-types,validate-diagram,session-info,check-edge,get-selection;isReadOnlyto change). The model is offered only the tools the server lists, lessMCP_RESOURCE_TOOLS(diagram-png,diagram-svg: server-mcp's image resources, which it lists as tools but a headless istar session can't serve, #157) andexclude.boundArgsare sent with every call and hidden from the model, e.g.@istar-ts/glsp's session:const server = serveIstarGlsp({ shared: { store, dispatcher }, mcp: true }); // @istar-ts/glsp/browser const client = new Client({ name: 'agent', version: '1.0.0' }); await client.connect( new StreamableHTTPClientTransport(server.mcp.url, { fetch: server.mcp.fetch }), ); const tools = await createMcpToolRegistry(client, { boundArgs: { sessionId: server.sessionId! }, exclude: ['save-model'], });A guard's rejection through
@istar-ts/glsparrives with its own diagnostics (#136).@istar-ts/agent/glsp'sopenGlspMcpSession(getOptions)sets all of this up over a host's store and dispatcher (anyActionDispatcher, acreateRemoteDispatcherincluded, #249), with Propose mode'sChangeSetTargeton a scratch GLSP session (changeSet). A scratch step's result carries itsstepIdand anundo/redo's itsundoDescription, sochangeSet.stepanddescribeUndowork on copies of the results too. Where the model lives in a worker,serveGlspMcpSession(port, getOptions)serves the session there andconnectGlspMcpSession(port)is the sameGlspMcpSessionon the page (#248):// worker, next to the store and dispatcher serveGlspMcpSession(port1, () => ({ store, dispatcher, metamodel })); // page const session = await connectGlspMcpSession(port2); await runAgent({ client, prompt, changeSet: session.changeSet });B, actions —
createActionToolRegistry(dispatcher, { metamodel }): one tool perHANDLED_ACTION_KINDSoperation and request, plusglspUndo/glspRedo, named after the kind with.as_(istar_setLinkValue). Element and link kinds in the schemas come from the metamodel.requestModelanswers without layout (actors, elements, dependums, links).
Host extensions
A host gives the agent what isn't in the metamodel — type definitions, a notation, pooled
values, an external validator — as @istar-ts/actions' HostContextProviders and
HostTools (#175): opaque data plus a handler, so nothing here knows the host's domain, and the
same values work on both backends (see @istar-ts/glsp's README, "Host tools and context
providers", for the types and an example).
- Context:
runAgent({ ..., context: providers, getModel: store.getModel })appends one section per provider to the system prompt, aftersystem(DEFAULT_SYSTEM_PROMPTby default, never forked):## title(default the name), then its text, read at the start of the run (fromgetModel()for a provider whose text is a function; withoutgetModelsuch a provider throws).prompt: falseleaves a provider out.systemPromptWith(system, providers, getModel)builds the same prompt for a host that drives aChatClientitself. On backend A,@istar-ts/glsp's launchers also serve the providers as MCP resources (or, in server-mcp's defaultdataMode, read-only tools). - Tools, backend A: register them on the launcher (
mcp: { hostTools }):tools/listlists them with theirreadOnlyHint, socreateMcpToolRegistryoffers them like any other tool,sessionIdbound. - Tools, backend B:
createActionToolRegistry(dispatcher, { hostTools, getModel })lists them after the action tools (readOnlyas declared) and calls the handler with the dispatcher andgetModel— read-only tools get one that takes requests only.createActionChangeSettakeshostToolstoo: the scratch run records what a host tool dispatched and the dispatcher accepted (ToolResult.dispatched, never shown to the model), andapplyreplays exactly that in the onecompound.
Approval
- Propose (default).
changeSet: ChangeSetTarget. A run opens a scratch copy of the live model and sends every call there; the edits are collected, with their dry-run results, intoAgentRun.changeSet. Read-only calls (a host tool declaredreadOnlyincluded) run straight away (on the scratch copy, so the model sees what it has proposed so far) and are not part of the change set.createActionChangeSetcopies the model asparsePistar(toPistar(model))(never the live store), keeps the host's guards and schemas, andapplydispatches the reviewed entries as onecompound. Refused entries are left out (they changed nothing in the dry run); undo/redo in the dry run are played out on the list. Backend B pins the ids a create would leave to the store (prepare), so the ids the model saw in the dry run are the ones the applied set creates. Exception: acreateEdgeto an existing n-ary hub (a new leg) gets the store's id. - Auto.
approval: 'auto', tools: registry: each call applies live as it comes.
The loop
runAgent sends messages + tools, runs the returned tool_calls one after another (a later
call may use what an earlier one created), appends each result and asks again. It stops on a
final answer, after maxSteps model calls (default 20), at budget.maxTokens / budget.maxCost
(with budget.cost, e.g. costPerMillionTokens({ input, cachedInput, output })), on signal, or
after maxRefusals refused calls in a row (default 3). It does not throw for API or tool
failures; transcript.stop says why it stopped.
Chat client
createChatClient({ baseUrl, model, apiKey, stream?, body?, fetch? }) posts to
{baseUrl}/chat/completions. Nothing in it is DeepSeek's: DEEPSEEK is just a baseUrl and the
default model deepseek-flash (docs/agent/verification.md). body adds request fields, e.g.
{ thinking: { type: 'disabled' } }. With stream: true the response is read as server-sent
events: text deltas reach onEvent as they arrive, and tool calls are buffered by index until
the stream ends, then run whole (DeepSeek streams tool calls in argument fragments, D0 §2).
Transcript
transcript is plain JSON for a UI to render and an evaluation to score: the messages, each
step's assistant message, usage and latency, each tool call's raw and prepared arguments,
disposition (read / applied / proposed), result and latency, the usage totals (including
cache hits and reasoning tokens), the cost, and the stop reason. The API key lives only in
createChatClient's closure; the ChatClient object has no field holding it and the loop records
only client.model, so a transcript cannot contain the key. An API error's body is redacted of
the key before it reaches stop.message.
Evaluation runner
scripts/evaluate.ts (#126 D5, #132) is a headless script, not a vitest suite: it runs the loop
over fixtures/ under controlled task families (retrieval, modification, large-model against
smartHome.txt's 237 ids, conflict) × conditions (Text, Actions with no guards, Actions with
the shield), 5 fresh runs each, and writes a JSON and markdown report of the level-7 evaluation
metrics (precision/recall, syntactic/semantic errors, rejections caught, a destroyed model,
tokens, wall-clock time, cost). It needs a real provider key and is never run in CI:
DEEPSEEK_API_KEY=sk-... pnpm --filter @istar-ts/agent evaluate -- --out report.json--base-url, --model, --api-key-env, --backend (A or B, default B) and --runs
(default 5) are configurable (#126 §6's note about re-running a baseline against a different
model). Both backends run every condition, including the shield, now that #136 (#129, D2) lands
a guard's typed diagnostics over MCP too. evaluate.ts's modules (eval-tasks.ts,
eval-conditions.ts, eval-metrics.ts, eval-runner.ts, eval-report.ts) have a vitest suite
(test/evaluate.test.ts) covering the harness plumbing — task definitions, metric aggregation,
the Text condition's parse check — with a mock chat server, no key.
