Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion typescript/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"start": "node dist/main.js"
},
"dependencies": {
"@smooai/smooth-operator-core": "^0.1.1",
"@smooai/smooth-operator-core": "^0.20.1",
"ws": "^8.18.0"
},
"devDependencies": {
Expand Down
11 changes: 11 additions & 0 deletions typescript/server/src/frameDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { randomUUID } from 'node:crypto';

import { ANONYMOUS_ACCESS, type AccessContext } from './auth.js';
import { ConfirmationRegistry } from './confirmation.js';
import type { ModelCeilingResolver } from './modelCeiling.js';
import * as protocol from './protocol.js';
import type { Frame } from './protocol.js';
import type { SessionStore } from './sessionStore.js';
Expand Down Expand Up @@ -52,6 +53,10 @@ export interface FrameDispatcherOptions {
* connection). Created on demand if not supplied.
*/
confirmations?: ConfirmationRegistry;
/** Model id for turns (default {@link DEFAULT_MODEL}); forwarded to the {@link TurnRunner}. */
model?: string;
/** Best-effort per-model output-ceiling resolver; forwarded to the {@link TurnRunner} (EPIC th-1cc9fa). */
modelCeiling?: ModelCeilingResolver;
}

export class FrameDispatcher {
Expand All @@ -63,6 +68,8 @@ export class FrameDispatcher {
private readonly tools: Tool[];
private readonly confirmTools: string[];
private readonly confirmations: ConfirmationRegistry;
private readonly model?: string;
private readonly modelCeiling?: ModelCeilingResolver;
/** In-flight spawned `send_message` turns, tracked so teardown can await them. */
private readonly turns = new Set<Promise<void>>();

Expand All @@ -75,6 +82,8 @@ export class FrameDispatcher {
this.tools = options.tools ?? [];
this.confirmTools = options.confirmTools ?? [];
this.confirmations = options.confirmations ?? new ConfirmationRegistry();
this.model = options.model;
this.modelCeiling = options.modelCeiling;
}

/**
Expand Down Expand Up @@ -213,6 +222,8 @@ export class FrameDispatcher {
confirmTools: this.confirmTools,
confirmations: this.confirmations,
sessionId,
model: this.model,
modelCeiling: this.modelCeiling,
});

// Run the turn as a background task, NOT awaited inline. A turn that calls a
Expand Down
5 changes: 4 additions & 1 deletion typescript/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ export type { AccessKnowledge, FrameDispatcherOptions } from './frameDispatcher.

export { ConfirmationRegistry } from './confirmation.js';

export { TurnRunner } from './turnRunner.js';
export { DEFAULT_MAX_ITERATIONS, DEFAULT_MAX_TOKENS, DEFAULT_MODEL, TurnRunner } from './turnRunner.js';
export type { Sink, TurnResult, TurnRunnerOptions } from './turnRunner.js';

export { createGatewayModelCeilingResolver, extractModelCeilings } from './modelCeiling.js';
export type { FetchLike, ModelCeilingResolver } from './modelCeiling.js';

export { InMemorySessionStore } from './sessionStore.js';
export type { MessageDirection, SessionStore, StoredMessage, StoredSession } from './sessionStore.js';

Expand Down
29 changes: 25 additions & 4 deletions typescript/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,31 @@
* SMOOTH_OPERATOR_PORT bind port (default 8787)
* SMOOAI_GATEWAY_URL OpenAI-compatible base URL (enables live turns with a key)
* SMOOAI_GATEWAY_KEY gateway API key
* SMOOAI_MODEL model id (default gpt-4o-mini)
* SMOOAI_MODEL model id (default claude-haiku-4-5)
*/
import type { ChatClientLike } from '@smooai/smooth-operator-core';

import { createGatewayModelCeilingResolver, type ModelCeilingResolver } from './modelCeiling.js';
import { serveLocal } from './server.js';

/** The model id turns run against — SMOOAI_MODEL, else the engine's default. */
function resolveModel(): string {
return process.env.SMOOAI_MODEL ?? 'claude-haiku-4-5';
}

/**
* A per-model output-ceiling resolver backed by the gateway's `/model/info`, so each
* turn clamps `max_tokens` to what the model can physically emit (EPIC th-1cc9fa). Only
* built when a gateway url+key are configured; otherwise `undefined` ⇒ turns run
* unclamped (behaviour unchanged on the keyless local path).
*/
function buildModelCeiling(): ModelCeilingResolver | undefined {
const url = process.env.SMOOAI_GATEWAY_URL;
const key = process.env.SMOOAI_GATEWAY_KEY;
if (!url || !key) return undefined;
return createGatewayModelCeilingResolver(url, key);
}

/**
* A keyless client: every model call rejects, so `send_message` surfaces a clean
* protocol error (the dispatcher's catch → INTERNAL_ERROR) instead of hanging. The
Expand Down Expand Up @@ -53,8 +72,10 @@ async function buildChatClient(): Promise<ChatClientLike> {
// keyless local flavor needs no LLM SDK). Production installs it alongside.
const openaiModule = 'openai';
const mod = (await import(openaiModule)) as { default: new (opts: { apiKey: string; baseURL: string }) => ChatClientLike };
const model = process.env.SMOOAI_MODEL ?? 'gpt-4o-mini';
process.env.SMOOAI_MODEL = model;
// Pin the resolved model into the env so the turn runner and the ceiling lookup
// agree on which model is in play (the request model and its /model/info ceiling
// must be the same model).
process.env.SMOOAI_MODEL = resolveModel();
return new mod.default({ apiKey: key, baseURL: url });
} catch {
// The `openai` package isn't installed — fall back to the keyless client so
Expand All @@ -68,7 +89,7 @@ async function main(): Promise<void> {
const port = Number(process.env.SMOOTH_OPERATOR_PORT ?? '8787');
const chatClient = await buildChatClient();

const server = await serveLocal({ chatClient, host, port });
const server = await serveLocal({ chatClient, host, port, model: resolveModel(), modelCeiling: buildModelCeiling() });
// eslint-disable-next-line no-console
console.log(`smooth-operator-server (TypeScript, local flavor) listening on ${server.url}`);
// serveLocal already wires SIGTERM/SIGINT → graceful drain + close.
Expand Down
93 changes: 93 additions & 0 deletions typescript/server/src/modelCeiling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Per-model output-ceiling lookup from the LLM gateway's `/model/info` — the
* consumer half of the model-output clamp (EPIC th-1cc9fa).
*
* The engine (`@smooai/smooth-operator-core`) clamps a turn's `max_tokens` to
* `min(maxTokens, modelMaxOutput)` but takes no LiteLLM-specific HTTP itself. The
* server sources the ceiling here and passes it in via `AgentOptions.modelMaxOutput`,
* mirroring the Rust server's `admin::fetch_model_costs` / `model_output_ceiling` /
* `map_model_info` split (`rust/smooth-operator-server/src/admin.rs`).
*
* **Best-effort**: any gateway/transport/decode error, an unknown model, or a model
* whose gateway entry has no ceiling ⇒ `undefined` ⇒ the engine leaves `max_tokens`
* unclamped (graceful, no behaviour change). The `/model/info` map is fetched at most
* once per process (cached on first success; failures are not cached, so the next turn
* retries).
*/

/** Resolve a model id to its hard output ceiling (`max_output_tokens`), or `undefined`. */
export type ModelCeilingResolver = (model: string) => Promise<number | undefined>;

/** The subset of `fetch` this module needs — injectable so the resolver is unit-testable offline. */
export type FetchLike = (url: string, init?: { headers?: Record<string, string> }) => Promise<{ ok: boolean; json: () => Promise<unknown> }>;

/**
* Map a gateway `/model/info` payload
* (`{ data: [{ model_name, model_info: { max_output_tokens, … } }] }`) to a
* `model_name → max_output_tokens` map. The TS analog of the Rust `map_model_info`,
* narrowed to the one field the clamp needs. Entries without a `model_name`, without a
* positive integer `max_output_tokens`, are skipped. Pure + network-free so it's
* unit-testable on a sample payload.
*/
export function extractModelCeilings(payload: unknown): Map<string, number> {
const out = new Map<string, number>();
const data = (payload as { data?: unknown })?.data;
if (!Array.isArray(data)) return out;
for (const entry of data) {
if (typeof entry !== 'object' || entry === null) continue;
const name = (entry as { model_name?: unknown }).model_name;
if (typeof name !== 'string' || name.length === 0) continue;
const info = (entry as { model_info?: unknown }).model_info;
const max = typeof info === 'object' && info !== null ? (info as { max_output_tokens?: unknown }).max_output_tokens : undefined;
if (typeof max === 'number' && Number.isInteger(max) && max > 0) {
out.set(name, max);
}
}
return out;
}

/**
* Build a {@link ModelCeilingResolver} backed by the gateway's `/model/info`.
*
* `gatewayUrl` is the OpenAI-compatible base url (e.g. `https://llm.smoo.ai/v1`); the
* model-info endpoint is `{gatewayUrl}/model/info`. `gatewayKey`, when present, is
* sent as a bearer token (the same creds the turns use). The whole model map is
* fetched at most once per process on the first lookup and cached; a lost race just
* recomputes the same stable map. Any error ⇒ every lookup returns `undefined` (and
* the failure is not cached, so a later turn retries).
*/
export function createGatewayModelCeilingResolver(gatewayUrl: string, gatewayKey?: string, fetchImpl: FetchLike = fetch as unknown as FetchLike): ModelCeilingResolver {
const url = `${gatewayUrl.replace(/\/+$/, '')}/model/info`;
let cached: Map<string, number> | undefined;
let inflight: Promise<Map<string, number>> | undefined;

const load = async (): Promise<Map<string, number>> => {
if (cached) return cached;
// Single-flight: concurrent first turns share one fetch rather than stampeding
// the gateway. On failure we return an empty map WITHOUT caching, so the next
// turn retries (matches the Rust best-effort-not-cached-on-error behaviour).
if (!inflight) {
inflight = (async () => {
try {
const res = await fetchImpl(url, gatewayKey ? { headers: { authorization: `Bearer ${gatewayKey}` } } : undefined);
if (!res.ok) throw new Error(`gateway /model/info returned non-ok`);
const map = extractModelCeilings(await res.json());
cached = map;
return map;
} finally {
inflight = undefined;
}
})();
}
try {
return await inflight;
} catch {
return new Map();
}
};

return async (model: string): Promise<number | undefined> => {
const ceiling = (await load()).get(model);
return ceiling !== undefined && ceiling > 0 ? ceiling : undefined;
};
}
22 changes: 21 additions & 1 deletion typescript/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { WebSocketServer, type WebSocket } from 'ws';
import { ANONYMOUS_ACCESS } from './auth.js';
import { Backplane, InMemoryBackplane } from './backplane.js';
import { type AccessKnowledge, FrameDispatcher } from './frameDispatcher.js';
import type { ModelCeilingResolver } from './modelCeiling.js';
import type { Frame } from './protocol.js';
import type { ChatClientLike, Tool } from '@smooai/smooth-operator-core';
import type { AuthVerifier } from './auth.js';
Expand Down Expand Up @@ -51,6 +52,14 @@ export interface ServerOptions {
* client replies with `confirm_tool_action`.
*/
confirmTools?: string[];
/** Model id for turns (default the engine's own default); forwarded to each connection's dispatcher. */
model?: string;
/**
* Best-effort per-model output-ceiling resolver (from the gateway's `/model/info`).
* When set, each turn clamps `max_tokens` to what the model can physically emit
* (EPIC th-1cc9fa). Absent (tests, keyless local) ⇒ unclamped, behaviour unchanged.
*/
modelCeiling?: ModelCeilingResolver;
/** WS path to mount on (default `/ws`). */
path?: string;
}
Expand Down Expand Up @@ -108,6 +117,8 @@ export function buildServer(options: ServerOptions): {
systemPrompt: options.systemPrompt,
tools: options.tools,
confirmTools: options.confirmTools,
model: options.model,
modelCeiling: options.modelCeiling,
});
// Fire-and-forget the per-connection loop; it owns the socket's lifecycle.
void runConnection(socket, dispatcher, backplane, drain.signal);
Expand Down Expand Up @@ -167,13 +178,22 @@ export async function serve(options: ServerOptions & { host?: string; port?: num
* embeddable in-process. The TS analog of the Rust `serve_local` / `LocalServer`
* and the C# in-memory host. Everything in memory, loopback bind, no auth.
*/
export async function serveLocal(options: { chatClient: ChatClientLike; host?: string; port?: number; knowledge?: AccessKnowledge }): Promise<RunningServer> {
export async function serveLocal(options: {
chatClient: ChatClientLike;
host?: string;
port?: number;
knowledge?: AccessKnowledge;
model?: string;
modelCeiling?: ModelCeilingResolver;
}): Promise<RunningServer> {
return serve({
chatClient: options.chatClient,
store: new InMemorySessionStore(),
auth: new NoAuthVerifier(),
backplane: new InMemoryBackplane(),
knowledge: options.knowledge,
model: options.model,
modelCeiling: options.modelCeiling,
host: options.host ?? '127.0.0.1',
port: options.port ?? 0,
});
Expand Down
47 changes: 46 additions & 1 deletion typescript/server/src/turnRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { approve, deny, SmoothAgent } from '@smooai/smooth-operator-core';
import type { AgentOptions, ChatClientLike, HumanApprovalRequest, HumanApprovalResponse, Knowledge, StreamEvent, Tool } from '@smooai/smooth-operator-core';

import type { ConfirmationRegistry } from './confirmation.js';
import type { ModelCeilingResolver } from './modelCeiling.js';
import * as protocol from './protocol.js';
import type { Citation, Frame } from './protocol.js';
import type { SessionStore } from './sessionStore.js';
Expand All @@ -30,6 +31,25 @@ const AUTO_CONTEXT_LIMIT = 3;
const MAX_PRIOR_MESSAGES = 50;
const CITATION_SNIPPET_MAX_CHARS = 280;

/**
* Default model when the host doesn't set one. Matches the engine's own default so
* behaviour is unchanged; also the model the per-turn output ceiling is looked up for.
*/
export const DEFAULT_MODEL = 'claude-haiku-4-5';
/**
* Default agent-loop iteration cap. Was the engine's chat-widget-sized 6 — too tight
* for any multi-step turn. Raised to 20 for agentic use (EPIC th-1cc9fa).
*/
export const DEFAULT_MAX_ITERATIONS = 20;
/**
* Default `max_tokens` per LLM call. Was 512 (chat-widget sizing), which STARVES
* reasoning models — they spend the whole budget on `reasoning_content` and return
* empty `content`. Raised to 8192 (EPIC th-1cc9fa). Safe now that the per-model output
* ceiling clamps this down per request: a cap only bounds runaway output, it doesn't
* lengthen concise answers.
*/
export const DEFAULT_MAX_TOKENS = 8192;

const DEFAULT_SYSTEM_PROMPT =
'You are a helpful customer support agent. Answer using only the knowledge provided to you; if it is not there, say you don\'t know.';

Expand All @@ -55,6 +75,14 @@ export interface TurnRunnerOptions {
confirmations?: ConfirmationRegistry;
/** The session id a parked confirmation is keyed by (so a `confirm_tool_action` frame routes here). */
sessionId?: string;
/** Model id for the turn (default {@link DEFAULT_MODEL}); also the model whose output ceiling is looked up. */
model?: string;
/**
* Best-effort per-model output-ceiling resolver (from the gateway's `/model/info`).
* When set, each turn clamps `max_tokens` to `min(DEFAULT_MAX_TOKENS, ceiling)` via
* the engine's `modelMaxOutput`. Absent (tests, keyless local) ⇒ unclamped (EPIC th-1cc9fa).
*/
modelCeiling?: ModelCeilingResolver;
}

export class TurnRunner {
Expand All @@ -66,6 +94,8 @@ export class TurnRunner {
private readonly confirmTools: string[];
private readonly confirmations?: ConfirmationRegistry;
private readonly sessionId?: string;
private readonly model: string;
private readonly modelCeiling?: ModelCeilingResolver;

constructor(options: TurnRunnerOptions) {
this.chatClient = options.chatClient;
Expand All @@ -76,6 +106,8 @@ export class TurnRunner {
this.confirmTools = options.confirmTools ?? [];
this.confirmations = options.confirmations;
this.sessionId = options.sessionId;
this.model = options.model ?? DEFAULT_MODEL;
this.modelCeiling = options.modelCeiling;
}

/** True when `name` matches a confirmation-gated pattern (substring, like the Rust hook). */
Expand Down Expand Up @@ -112,10 +144,23 @@ export class TurnRunner {
// 2. Build the agent + replay prior history as the thread (before persisting
// this turn's inbound message). The engine consumes history as OpenAI-format
// messages passed to runStream.
const agentOptions: AgentOptions = { instructions: this.systemPrompt };
const agentOptions: AgentOptions = {
instructions: this.systemPrompt,
model: this.model,
maxTokens: DEFAULT_MAX_TOKENS,
maxIterations: DEFAULT_MAX_ITERATIONS,
};
if (this.knowledge) agentOptions.knowledge = this.knowledge;
if (this.tools.length > 0) agentOptions.tools = this.tools;

// Clamp max_tokens to the resolved model's output ceiling (best-effort; a
// missing/unknown ceiling ⇒ unclamped). Reuses the cached /model/info fetch.
// EPIC th-1cc9fa — the consumer half of the engine's model-output clamp.
if (this.modelCeiling) {
const ceiling = await this.modelCeiling(this.model);
if (ceiling !== undefined) agentOptions.modelMaxOutput = ceiling;
}

// Write-confirmation HITL: when configured with tool patterns AND a registry
// is present, install a HumanGate that parks the turn before a gated tool runs
// (emit `write_confirmation_required`, await the client's verdict via the
Expand Down
Loading