@openshain/agent 0.1.1 → 0.3.1

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.
@@ -0,0 +1,251 @@
1
+ import { OpenshainError, } from "@openshain/core";
2
+ import OpenAI, {} from "openai";
3
+ export const OPENAI_COMPATIBLE_PROVIDER_ID = "openai-compatible";
4
+ /** Used when the request names no output limit. The config's limit normally does. */
5
+ const DEFAULT_MAX_TOKENS = 16_000;
6
+ /**
7
+ * Builds the provider from the model section of openshain.yaml. The key comes from the environment
8
+ * variable the config names; `options: { tools: false }` declares an endpoint without tool support.
9
+ */
10
+ export function openaiCompatibleProvider(model, env = process.env) {
11
+ const apiKey = env[model.apiKeyEnv];
12
+ if (!apiKey) {
13
+ throw new OpenshainError("config", `environment variable ${model.apiKeyEnv} is not set; it should hold the API key`);
14
+ }
15
+ return new OpenAICompatibleProvider({
16
+ model: model.model,
17
+ apiKey,
18
+ ...(model.baseUrl && { baseUrl: model.baseUrl }),
19
+ ...(model.options?.tools === false && { tools: false }),
20
+ });
21
+ }
22
+ /** Any chat completions endpoint with function calling: OpenAI, a local server, or another vendor's compatible API. */
23
+ export class OpenAICompatibleProvider {
24
+ id = OPENAI_COMPATIBLE_PROVIDER_ID;
25
+ client;
26
+ model;
27
+ tools;
28
+ constructor(options) {
29
+ this.model = options.model;
30
+ this.tools = options.tools ?? true;
31
+ this.client = new OpenAI({
32
+ apiKey: options.apiKey.trim(),
33
+ ...(options.baseUrl && { baseURL: options.baseUrl }),
34
+ ...(options.fetch && { fetch: options.fetch }),
35
+ });
36
+ }
37
+ describe() {
38
+ return {
39
+ provider: OPENAI_COMPATIBLE_PROVIDER_ID,
40
+ model: this.model,
41
+ capabilities: { tools: this.tools },
42
+ };
43
+ }
44
+ async generate(request, signal) {
45
+ const params = toParams(request, this.model);
46
+ try {
47
+ const completion = await this.client.chat.completions.create(params, {
48
+ ...(signal && { signal }),
49
+ });
50
+ return fromCompletion(completion);
51
+ }
52
+ catch (err) {
53
+ throw toError(err);
54
+ }
55
+ }
56
+ }
57
+ /**
58
+ * The request as chat completions take it. providerOptions land on the body as they are
59
+ * (reasoning_effort, temperature, and so on); the model, the limit, the messages, the tools and
60
+ * the choice not to stream come from the runtime. The limit is sent as max_completion_tokens; a
61
+ * max_tokens in the options only asks for that older name, which some servers still expect,
62
+ * and its value is ignored. A `tools` flag in the options is the provider's, not the request's.
63
+ */
64
+ export function toParams(request, model) {
65
+ const { tools: _flag, model: _model, messages: _messages, stream: _stream, max_completion_tokens: _limit, max_tokens: legacy, ...extra } = request.providerOptions ?? {};
66
+ const limit = request.maxOutputTokens ?? DEFAULT_MAX_TOKENS;
67
+ const messages = [];
68
+ if (request.system)
69
+ messages.push({ role: "system", content: request.system });
70
+ for (const message of request.messages)
71
+ messages.push(...toMessages(message));
72
+ return {
73
+ ...extra,
74
+ model,
75
+ stream: false,
76
+ ...(legacy !== undefined ? { max_tokens: limit } : { max_completion_tokens: limit }),
77
+ messages,
78
+ ...(request.tools && request.tools.length > 0 && { tools: request.tools.map(toTool) }),
79
+ };
80
+ }
81
+ function toTool(tool) {
82
+ return {
83
+ type: "function",
84
+ function: { name: tool.name, description: tool.description, parameters: tool.inputSchema },
85
+ };
86
+ }
87
+ /** One contract message becomes one or more chat messages: every tool result is a message of its own. */
88
+ function toMessages(message) {
89
+ if (message.role === "user") {
90
+ const out = [];
91
+ const texts = [];
92
+ for (const part of message.content) {
93
+ if (part.type === "text") {
94
+ if (part.text !== "")
95
+ texts.push(part.text);
96
+ }
97
+ else {
98
+ out.push({ role: "tool", tool_call_id: part.callId, content: part.content });
99
+ }
100
+ }
101
+ if (texts.length > 0)
102
+ out.push({ role: "user", content: texts.join("\n") });
103
+ return out;
104
+ }
105
+ const texts = [];
106
+ const toolCalls = [];
107
+ let opaque = {};
108
+ for (const part of message.content) {
109
+ if (part.type === "text") {
110
+ if (part.text !== "")
111
+ texts.push(part.text);
112
+ }
113
+ else if (part.type === "tool_call") {
114
+ toolCalls.push({
115
+ id: part.id,
116
+ type: "function",
117
+ function: { name: part.name, arguments: JSON.stringify(part.input ?? {}) },
118
+ });
119
+ }
120
+ else if (part.provider === OPENAI_COMPATIBLE_PROVIDER_ID) {
121
+ opaque = { ...opaque, ...part.data };
122
+ }
123
+ }
124
+ const { unsupported_tool_calls: _unsupported, ...sent } = opaque;
125
+ if (texts.length === 0 && toolCalls.length === 0 && Object.keys(sent).length === 0) {
126
+ throw new OpenshainError("invalid_response", "an assistant message has nothing this provider can send; it may belong to another provider");
127
+ }
128
+ return [
129
+ {
130
+ ...sent,
131
+ role: "assistant",
132
+ content: texts.length > 0 ? texts.join("\n") : null,
133
+ ...(toolCalls.length > 0 && { tool_calls: toolCalls }),
134
+ },
135
+ ];
136
+ }
137
+ /**
138
+ * The completion in the contract's terms. Fields of the assistant message beyond content and
139
+ * tool calls, such as a server's reasoning, are kept opaque and go back with the message. Tool
140
+ * calls that are not function calls are kept opaque too, but never sent back. A refusal's text
141
+ * becomes text, so the log says why. A response whose shape is not a completion is an invalid
142
+ * response.
143
+ */
144
+ export function fromCompletion(completion) {
145
+ const choice = completion?.choices?.[0];
146
+ if (!choice?.message) {
147
+ throw new OpenshainError("invalid_response", "the completion has no message");
148
+ }
149
+ const { role: _role, content: rawContent, tool_calls, refusal, ...rest } = choice.message;
150
+ const content = [];
151
+ const text = joinContent(rawContent);
152
+ if (text)
153
+ content.push({ type: "text", text });
154
+ if (refusal)
155
+ content.push({ type: "text", text: refusal });
156
+ const calls = (tool_calls ?? []).filter((call) => call.type === "function");
157
+ for (const call of calls) {
158
+ content.push({
159
+ type: "tool_call",
160
+ id: call.id,
161
+ name: call.function.name,
162
+ input: parseArguments(call.function.arguments),
163
+ });
164
+ }
165
+ const unsupported = (tool_calls ?? []).filter((call) => call.type !== "function");
166
+ const opaque = Object.fromEntries(Object.entries(rest).filter(([key, value]) => key !== "annotations" && value != null));
167
+ if (unsupported.length > 0)
168
+ opaque.unsupported_tool_calls = unsupported;
169
+ if (Object.keys(opaque).length > 0) {
170
+ content.push({ type: "opaque", provider: OPENAI_COMPATIBLE_PROVIDER_ID, data: opaque });
171
+ }
172
+ return {
173
+ message: { role: "assistant", content },
174
+ stopReason: toStopReason(choice.finish_reason, calls.length > 0),
175
+ usage: toUsage(completion.usage),
176
+ raw: completion,
177
+ };
178
+ }
179
+ /** Content is a string, but some servers return text parts; those are joined. */
180
+ function joinContent(content) {
181
+ if (typeof content === "string")
182
+ return content;
183
+ if (!Array.isArray(content))
184
+ return "";
185
+ return content
186
+ .map((part) => part && typeof part === "object" ? part.text : undefined)
187
+ .filter((text) => typeof text === "string")
188
+ .join("");
189
+ }
190
+ /** Tool arguments arrive as a JSON string. One that does not parse is passed on as it is, so the schema check reports it. */
191
+ function parseArguments(args) {
192
+ if (args.trim() === "")
193
+ return {};
194
+ try {
195
+ return JSON.parse(args);
196
+ }
197
+ catch {
198
+ return args;
199
+ }
200
+ }
201
+ const STOP_REASONS = {
202
+ stop: "end_turn",
203
+ tool_calls: "tool_call",
204
+ length: "max_tokens",
205
+ content_filter: "refusal",
206
+ };
207
+ /** Some servers say `stop` even when they made tool calls; the function calls decide either way. */
208
+ function toStopReason(reason, hasToolCalls) {
209
+ if (hasToolCalls && reason !== "length")
210
+ return "tool_call";
211
+ if (reason === "tool_calls" && !hasToolCalls)
212
+ return "other";
213
+ return (reason && STOP_REASONS[reason]) || "other";
214
+ }
215
+ function toUsage(usage) {
216
+ const cached = usage?.prompt_tokens_details?.cached_tokens;
217
+ const reasoning = usage?.completion_tokens_details?.reasoning_tokens;
218
+ return {
219
+ inputTokens: usage?.prompt_tokens ?? 0,
220
+ outputTokens: usage?.completion_tokens ?? 0,
221
+ ...(cached != null && { cachedInputTokens: cached }),
222
+ ...(reasoning != null && { reasoningTokens: reasoning }),
223
+ };
224
+ }
225
+ /** The SDK's typed errors as the codes the runtime records. Anything else means the response could not be read. */
226
+ function toError(err) {
227
+ if (err instanceof OpenshainError)
228
+ return err;
229
+ if (err instanceof OpenAI.APIUserAbortError)
230
+ return wrap("network", err);
231
+ if (err instanceof OpenAI.AuthenticationError)
232
+ return wrap("auth", err);
233
+ if (err instanceof OpenAI.PermissionDeniedError)
234
+ return wrap("auth", err);
235
+ if (err instanceof OpenAI.RateLimitError)
236
+ return wrap("rate_limit", err);
237
+ if (err instanceof OpenAI.BadRequestError)
238
+ return wrap("config", err);
239
+ if (err instanceof OpenAI.NotFoundError)
240
+ return wrap("config", err);
241
+ if (err instanceof OpenAI.APIConnectionError)
242
+ return wrap("network", err);
243
+ if (err instanceof OpenAI.InternalServerError)
244
+ return wrap("network", err);
245
+ if (err instanceof OpenAI.APIError)
246
+ return wrap("invalid_response", err);
247
+ return wrap("invalid_response", err instanceof Error ? err : new Error(String(err)));
248
+ }
249
+ function wrap(code, err) {
250
+ return new OpenshainError(code, `chat completions: ${err.message}`, { cause: err });
251
+ }
@@ -0,0 +1,50 @@
1
+ import { type AnyEvent, type Config, type ModelProvider, type Work, type WorkId } from "@openshain/core";
2
+ import { type RuntimeClient } from "./client.ts";
3
+ /** How much one turn of the conversation may do before it stops and the person is told. */
4
+ export declare const TURN_LIMITS: {
5
+ readonly modelCalls: 25;
6
+ readonly toolCalls: 40;
7
+ };
8
+ export interface SessionOptions {
9
+ /** The model the conversation runs on. The client owns it; the runtime never calls one. */
10
+ model: ModelProvider;
11
+ /** The workspace's configuration, for the prompt, the limits and the provider options. */
12
+ config: Pick<Config, "company" | "principal" | "profession" | "limits" | "model" | "debug">;
13
+ /** The name the agent goes by. Picked from the list, avoiding open sessions' names, when omitted. */
14
+ agentName?: string;
15
+ /** Called for every event the session records or sees: the session's own and the works'. A returned promise is awaited. */
16
+ onEvent?: (workId: WorkId, event: AnyEvent) => void | Promise<void>;
17
+ /** Answers a question a work asks the person. Without it, the work waits for input. */
18
+ onInput?: (workId: WorkId, question: string) => Promise<string>;
19
+ }
20
+ export type TurnStop = "turn_limit" | "aborted" | "max_tokens" | "refusal" | "model_error";
21
+ export interface TurnResult {
22
+ /** What the model said to the person, possibly empty when the turn stopped early. */
23
+ reply: string;
24
+ /** Why the turn ended before the model replied, if it did. */
25
+ stopped?: TurnStop;
26
+ detail?: string;
27
+ /** The work the turn left open, when it stopped inside one. It can be continued with select. */
28
+ work?: WorkId;
29
+ }
30
+ export interface Session {
31
+ readonly id: WorkId;
32
+ /** The name the agent goes by in this conversation and in the works it starts. */
33
+ readonly agentName: string;
34
+ /** Records what the person said and runs the model until it replies or the turn stops. */
35
+ turn(text: string, options?: {
36
+ signal?: AbortSignal;
37
+ }): Promise<TurnResult>;
38
+ /** Names a stopped work as the candidate for the next request. The model decides whether to continue it. */
39
+ select(workId: WorkId): Promise<Work>;
40
+ /** The work the model is on right now, if any. */
41
+ currentWork(): WorkId | undefined;
42
+ /** Ends the conversation. The record stays; a work left in progress stays in progress. */
43
+ close(): Promise<Work>;
44
+ }
45
+ /**
46
+ * Opens a conversation, recorded as a work of type "session", between the person and the model.
47
+ * The loop is a client of the runtime: it creates works, calls tools and closes works through
48
+ * the same MCP tools any other agent uses, and records its own model calls with work_record.
49
+ */
50
+ export declare function createSession(client: RuntimeClient, options: SessionOptions): Promise<Session>;