@niscorp/signal 0.1.0
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.
- package/DESIGN.md +139 -0
- package/DOCS.md +502 -0
- package/LICENSE +202 -0
- package/README.md +117 -0
- package/dist/index.cjs +1847 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +591 -0
- package/dist/index.d.ts +591 -0
- package/dist/index.js +1825 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
package/DESIGN.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Signal — Design Document
|
|
2
|
+
|
|
3
|
+
Architecture, design decisions, and trade-offs behind `@niscorp/signal`.
|
|
4
|
+
For usage documentation, see [DOCS.md](./DOCS.md).
|
|
5
|
+
|
|
6
|
+
One sentence: **what comes out conforms to the schema that went in, on
|
|
7
|
+
every provider — or you get a typed failure with evidence.**
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## The layer map
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
types.ts the public contract (incl. StepOutcome / Rejection / WireReport)
|
|
15
|
+
registry.ts provider DATA (baseUrl, endpoint capabilities, adapter id, wire strategy ids)
|
|
16
|
+
+ model DATA (model capabilities, reasoning efforts) — measured rows
|
|
17
|
+
scripts/ probe-model.ts: measures one model on one provider, prints its registry row
|
|
18
|
+
adapters/ byte movers, one per WIRE PROTOCOL (not per vendor) — throw-and-wrap, zero policy
|
|
19
|
+
decide/ gate.ts: the acceptance gate DERIVED from a decide() call's questions
|
|
20
|
+
wire/ RESPONSE side: repair.ts (mechanisms) → router.ts (classify) → strategies/ (provider quirks)
|
|
21
|
+
transport/ REQUEST side: resolve.ts (respond | native | emit) + protocol.ts (ALL prompt prose)
|
|
22
|
+
signal.ts the client: step/stepStream = the ONE execution core, builders, embed, decide, count
|
|
23
|
+
run.ts complete()/stream(): one loop over step — runStream is the core, runComplete drains it
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The seam rule: **wire is the response side, transport is the request
|
|
27
|
+
side, and everything between them is `step()`.** A change touching both
|
|
28
|
+
sides is two changes.
|
|
29
|
+
|
|
30
|
+
## Placement rules (decided once, checkable in review)
|
|
31
|
+
|
|
32
|
+
- A vendor is a **registry row**. Adapters exist per wire protocol;
|
|
33
|
+
Groq = the openai-compatible adapter + registry data (capabilities,
|
|
34
|
+
wire strategy ids). Never write a vendor adapter that duplicates a
|
|
35
|
+
protocol.
|
|
36
|
+
- A provider pathology is a **wire strategy**: one file under
|
|
37
|
+
`wire/strategies/`, fixtures from the real error/response body, id
|
|
38
|
+
listed on the provider's registry entry. Two hooks: `error` (recover
|
|
39
|
+
a `Rejection` from a thrown provider error — Groq `failed_generation`)
|
|
40
|
+
and `response` (contribute candidate texts — 4o JSONL). Adding a
|
|
41
|
+
quirk changes nothing else.
|
|
42
|
+
- A pure byte/value transform is a **repair mechanism** in
|
|
43
|
+
`wire/repair.ts` (extract, escape repair, jsonish decode, truncation
|
|
44
|
+
close). Mechanisms never validate and never know a provider; the
|
|
45
|
+
router composes them.
|
|
46
|
+
- Prompt prose exists in **exactly one file**: `transport/protocol.ts`
|
|
47
|
+
(finish protocols, corrections, the bare-schema prompt, the exit-tool
|
|
48
|
+
description). Prompt text anywhere else in signal is a review failure.
|
|
49
|
+
- Public contract types live in `types.ts`; module-internal types live
|
|
50
|
+
beside their code. No floating type files.
|
|
51
|
+
|
|
52
|
+
## Invariants
|
|
53
|
+
|
|
54
|
+
- **Request immutability.** The request shape (tools, toolChoice,
|
|
55
|
+
responseFormat) never changes during a run. All adaptation is
|
|
56
|
+
response-side. (Learned twice — mid-run tool masking and toolChoice
|
|
57
|
+
pinning both confuse models. Never again.)
|
|
58
|
+
- **Repairs are rescue-only.** A repaired candidate counts ONLY when it
|
|
59
|
+
passes the caller's acceptance schema; otherwise the original bytes
|
|
60
|
+
and the original error stand. This is why the ladder safely runs on
|
|
61
|
+
every response of every provider.
|
|
62
|
+
- **The router owns classification.** A response is `tool_calls`
|
|
63
|
+
(declared tool), `output` (anything whose repaired value passes the
|
|
64
|
+
acceptance gate — exit-tool args and pseudo-tool args included), or
|
|
65
|
+
`failed` with evidence (+ `attempted` when a parsed candidate existed
|
|
66
|
+
but failed the gate; + `truncated` when bytes end mid-structure).
|
|
67
|
+
Callers switch on the outcome; they never parse strings.
|
|
68
|
+
- **Rejections are arrivals.** A provider 400 that carries the model's
|
|
69
|
+
attempt is recovered by an error-hook strategy and routed
|
|
70
|
+
identically: a rejected call to a declared tool IS a tool call; a
|
|
71
|
+
rejected pseudo-call carrying valid output IS output.
|
|
72
|
+
- **Transport resolution is pure.** `resolveTransport(spec,
|
|
73
|
+
capabilities)` — previews resolve exactly like runs. `auto` picks what
|
|
74
|
+
ENFORCES: native when grammar is viable; respond when its tool params
|
|
75
|
+
can enforce the contract (the endpoint does not validate tool args
|
|
76
|
+
itself, the full schema fits, the model does not mangle nested args —
|
|
77
|
+
or `forceTool`); emit otherwise, because every model can do it and
|
|
78
|
+
the contract rides the prompt either way. Explicit choices are
|
|
79
|
+
honored. Permissive respond params on hard-validating providers
|
|
80
|
+
(`validatesToolArgs`) never advertise a field the contract lacks.
|
|
81
|
+
- **A capability is owned by the endpoint or by the model, never both.**
|
|
82
|
+
`EndpointCapabilities` live on the provider row, `ModelCapabilities`
|
|
83
|
+
on a model row keyed `provider/model`; a client's capabilities are
|
|
84
|
+
the join. Model rows are pasted from `scripts/probe-model.ts`, carry
|
|
85
|
+
the day they were measured, and are the only place a model fact
|
|
86
|
+
lives — no instance override, no app-side table. The invariants are
|
|
87
|
+
`test/registry.test.ts`.
|
|
88
|
+
|
|
89
|
+
## Design decisions (carried from v1 where still true)
|
|
90
|
+
|
|
91
|
+
1. **Factory function, not class.** Plain object from a closure.
|
|
92
|
+
2. **Immutable builder via spread.** Each method forks the config bag.
|
|
93
|
+
3. **String provider names**; registry supplies URLs, env keys,
|
|
94
|
+
endpoint and model capabilities; object config for custom endpoints.
|
|
95
|
+
4. **Capabilities drive behavior, not provider identity.** No
|
|
96
|
+
"if groq then X" outside the registry row.
|
|
97
|
+
5. **Zod is the source of truth.** Provider grammar is a compliance
|
|
98
|
+
hint; the acceptance schema gates what actually counts.
|
|
99
|
+
6. **Zero hard dependencies.** SDKs load dynamically; browsers inject a
|
|
100
|
+
client instead.
|
|
101
|
+
7. **History is external.** `complete()` returns full history; callers
|
|
102
|
+
thread it back.
|
|
103
|
+
8. **`step()`/`stepStream()` are the primitives; `complete()`/`stream()`
|
|
104
|
+
are wrappers over them.** One pipeline: orchestrators (cortex) and
|
|
105
|
+
the convenience API ride the same wire layer, the same routing, the
|
|
106
|
+
same recovery. Streaming is a delivery mode, never a second system.
|
|
107
|
+
9. **Streaming validation is end-of-stream**; mid-stream structural
|
|
108
|
+
parsing is `@niscorp/solid`'s job in the consumer.
|
|
109
|
+
10. **An adapter is one of two kinds, and the kind is the capability.**
|
|
110
|
+
`ChatAdapter | DecisionAdapter`, discriminated by `kind`, and the
|
|
111
|
+
registry row and the custom-provider config split the same way. A
|
|
112
|
+
decision model has no chat endpoint, so a decision adapter carrying a
|
|
113
|
+
`chat` that throws would be an adapter lying about what it is. There
|
|
114
|
+
is deliberately no `supportsDecisions` flag: a flag beside the kind is
|
|
115
|
+
a second truth that can disagree with the first. `describe().kind`
|
|
116
|
+
answers without the network.
|
|
117
|
+
11. **`decide()` sits beside the execution core, not in it.** Like
|
|
118
|
+
`embed`: one request, one response, no history, tools or loop — none
|
|
119
|
+
of the wire layer applies, because a decision model writes no bytes
|
|
120
|
+
to repair. The questions are the schema: the result type and the
|
|
121
|
+
gate (`decide/gate.ts`) both derive from them, which is the package's
|
|
122
|
+
one sentence applied to a model that answers instead of writing.
|
|
123
|
+
12. **Emulation is honest or absent.** On a chat adapter `decide()`
|
|
124
|
+
runs the derived schema through `complete()` and returns picks with
|
|
125
|
+
`calibrated: false` — the discriminant of `DecideResult`, so the
|
|
126
|
+
probabilities do not exist in the type on that branch. A fabricated
|
|
127
|
+
`1.0` would clear every caller's threshold. Same rule as
|
|
128
|
+
`usage.reported`.
|
|
129
|
+
|
|
130
|
+
## Known debt
|
|
131
|
+
|
|
132
|
+
- `adapters/anthropic.adapter.ts` and `google.adapter.ts` are stubs
|
|
133
|
+
that throw while the registry advertises both providers — either the
|
|
134
|
+
entries go or the adapters get built.
|
|
135
|
+
- The provider SPI types (`ProviderAdapter`, `ProviderRequest`,
|
|
136
|
+
`ProviderStreamDelta`) live in `types.ts`; they belong beside
|
|
137
|
+
`adapters/`.
|
|
138
|
+
- `step`/`stepStream` share ~30 lines of routing glue in signal.ts.
|
|
139
|
+
- `count()` is a chars/4 heuristic.
|
package/DOCS.md
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
# Signal Documentation
|
|
2
|
+
|
|
3
|
+
Complete reference for `@niscorp/signal`.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Concepts
|
|
8
|
+
|
|
9
|
+
### Stateless Immutable Builder
|
|
10
|
+
|
|
11
|
+
Signal uses an immutable builder pattern. Every configuration method returns a **new instance** — the original is never modified. `complete()` is the only method that executes.
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
const base = createSignal('groq').systemPrompt('You are helpful.');
|
|
15
|
+
|
|
16
|
+
// Fork for structured output — base is untouched
|
|
17
|
+
const structured = base.schema(z.object({ name: z.string() }));
|
|
18
|
+
|
|
19
|
+
// Fork for tools — base still untouched
|
|
20
|
+
const withTools = base.tools([myTool]);
|
|
21
|
+
|
|
22
|
+
// Call complete on any instance, any number of times
|
|
23
|
+
const r1 = await base.complete('hello');
|
|
24
|
+
const r2 = await structured.complete('Extract: Alice');
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Provider Abstraction
|
|
28
|
+
|
|
29
|
+
Signal doesn't care which LLM provider you use. Under the hood, it translates your request to the provider's API format, handles quirks (Groq can't do tools + structured output together), and normalizes the response.
|
|
30
|
+
|
|
31
|
+
### Capabilities
|
|
32
|
+
|
|
33
|
+
Each provider has a capability profile that Signal uses to pick the right strategy:
|
|
34
|
+
|
|
35
|
+
- **`nativeTools`** — Can the provider do tool calling natively?
|
|
36
|
+
- **`nativeJsonSchema`** — Can it enforce a JSON Schema on the response?
|
|
37
|
+
- **`nativeJsonMode`** — Can it guarantee JSON output?
|
|
38
|
+
- **`multimodal`** — Does it accept image content?
|
|
39
|
+
- **`supportsEmbedding`** — Can it convert text to vectors?
|
|
40
|
+
|
|
41
|
+
You don't need to think about this unless you're overriding defaults.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Creating a Signal
|
|
46
|
+
|
|
47
|
+
### Known Provider
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
const signal = createSignal('groq');
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Reads `GROQ_API_KEY` from environment automatically. Known providers: `'groq'`, `'openai'`, `'openrouter'`, `'anthropic'`, `'google'`.
|
|
54
|
+
|
|
55
|
+
### Known Provider with Options
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
const signal = createSignal('groq', {
|
|
59
|
+
apiKey: 'gsk_...',
|
|
60
|
+
model: 'qwen/qwen3.8-27b',
|
|
61
|
+
systemPrompt: 'You are a data extractor.',
|
|
62
|
+
retries: 3,
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Custom Provider
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
const signal = createSignal({
|
|
70
|
+
baseUrl: 'https://my-endpoint.com/v1',
|
|
71
|
+
apiKey: 'sk-...',
|
|
72
|
+
model: 'my-model',
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Any OpenAI-compatible API works.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Builder Methods
|
|
81
|
+
|
|
82
|
+
Every method returns a new `Signal` instance.
|
|
83
|
+
|
|
84
|
+
### `.model(name)`
|
|
85
|
+
Override the model for this instance.
|
|
86
|
+
```typescript
|
|
87
|
+
signal.model('qwen/qwen3.8-27b')
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### `.systemPrompt(prompt)`
|
|
91
|
+
Set the system prompt.
|
|
92
|
+
```typescript
|
|
93
|
+
signal.systemPrompt('You extract structured data from text.')
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `.schema(zodSchema)`
|
|
97
|
+
Set the output schema. The response will be typed, validated, and retried on failure.
|
|
98
|
+
```typescript
|
|
99
|
+
signal.schema(z.object({
|
|
100
|
+
name: z.string(),
|
|
101
|
+
age: z.number(),
|
|
102
|
+
}))
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### `.tools(tools)`
|
|
106
|
+
Set tools for tool calling. See [Tools](#tools) below.
|
|
107
|
+
```typescript
|
|
108
|
+
signal.tools([searchTool, calculatorTool])
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### `.history(messages)`
|
|
112
|
+
Set conversation history for multi-turn.
|
|
113
|
+
```typescript
|
|
114
|
+
signal.history(previousMessages)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### `.retries(count)`
|
|
118
|
+
Set max validation retries (default: 2). When a response fails Zod validation, Signal feeds the error back to the model and tries again.
|
|
119
|
+
```typescript
|
|
120
|
+
signal.retries(3)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### `.apiKey(key)`
|
|
124
|
+
Override the API key.
|
|
125
|
+
```typescript
|
|
126
|
+
signal.apiKey('gsk_...')
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `.options(opts)`
|
|
130
|
+
Set LLM options (temperature, maxTokens, etc.). These are rarely needed.
|
|
131
|
+
```typescript
|
|
132
|
+
signal.options({ temperature: 0 })
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Capabilities — looked up, not configured
|
|
136
|
+
What a client can do is the provider's registry row (what the ENDPOINT
|
|
137
|
+
does) joined with the model's registry row (what the MODEL does, as
|
|
138
|
+
measured by `scripts/probe-model.ts`). There is no override: a fact about
|
|
139
|
+
a model is a row in `src/registry.ts`. A model with no row resolves to the
|
|
140
|
+
conservative `UNMEASURED_MODEL`, and `describe().modelKnown` says so.
|
|
141
|
+
A custom provider (by `baseUrl`) declares its own `capabilities`.
|
|
142
|
+
```typescript
|
|
143
|
+
createSignal('groq').describe()
|
|
144
|
+
// { provider: 'groq', model: 'qwen/qwen3.8-27b', kind: 'chat', modelKnown: true, capabilities: {…} }
|
|
145
|
+
```
|
|
146
|
+
A `reasoningEffort` the model's row does not list is refused when the
|
|
147
|
+
client is built (`SignalError`), not by a provider 400 mid-run.
|
|
148
|
+
|
|
149
|
+
### `.onRetry(handler)`
|
|
150
|
+
Hook called on each validation retry.
|
|
151
|
+
```typescript
|
|
152
|
+
signal.onRetry((error, attempt) => console.log(`Retry ${attempt}:`, error.message))
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### `.onToolCall(handler)`
|
|
156
|
+
Hook called when a tool is executed.
|
|
157
|
+
```typescript
|
|
158
|
+
signal.onToolCall((name, args) => console.log(`Calling ${name}`, args))
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Execution
|
|
164
|
+
|
|
165
|
+
### `.complete(input)`
|
|
166
|
+
|
|
167
|
+
Execute the request. Returns `{ response, history, meta }`.
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
const { response, history, meta } = await signal.complete('What is 2+2?');
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`input` can be a string or multimodal content parts:
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
await signal.complete([
|
|
177
|
+
{ type: 'text', text: 'What is in this image?' },
|
|
178
|
+
{ type: 'image', source: { type: 'url', url: 'https://...' } },
|
|
179
|
+
]);
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### `.stream(input)`
|
|
183
|
+
|
|
184
|
+
Not yet implemented. Will return `AsyncIterable<StreamEvent<T>>`.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Response
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
type SignalResult<T> = {
|
|
192
|
+
response: T; // string if no schema, typed if schema set
|
|
193
|
+
history: Message[]; // full conversation including tool calls
|
|
194
|
+
meta: SignalMeta; // usage, timing, tool calls, provider details
|
|
195
|
+
};
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Meta
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
type SignalMeta = {
|
|
202
|
+
model: string; // actual model used
|
|
203
|
+
usage: {
|
|
204
|
+
inputTokens: number;
|
|
205
|
+
outputTokens: number;
|
|
206
|
+
totalTokens: number;
|
|
207
|
+
};
|
|
208
|
+
durationMs: number; // wall clock time
|
|
209
|
+
retries: number; // validation retries that occurred
|
|
210
|
+
toolCalls: ToolCallRecord[]; // tools called, args, results, timing
|
|
211
|
+
provider: {
|
|
212
|
+
raw: unknown; // raw API response for debugging
|
|
213
|
+
errors: ProviderError[]; // all errors including recovered ones
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Structured Output
|
|
221
|
+
|
|
222
|
+
Pass a Zod schema via `.schema()`. The response is parsed, validated, and typed.
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
const { response } = await createSignal('groq')
|
|
226
|
+
.schema(z.object({
|
|
227
|
+
name: z.string(),
|
|
228
|
+
age: z.number(),
|
|
229
|
+
city: z.string(),
|
|
230
|
+
}))
|
|
231
|
+
.complete('Extract: Alice is 30 years old and lives in Berlin.');
|
|
232
|
+
|
|
233
|
+
// response.name === 'Alice'
|
|
234
|
+
// response.age === 30
|
|
235
|
+
// response.city === 'Berlin'
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### How It Works
|
|
239
|
+
|
|
240
|
+
1. Zod schema is converted to JSON Schema via `z.toJSONSchema()`
|
|
241
|
+
2. Based on provider capabilities:
|
|
242
|
+
- `nativeJsonSchema` → sends JSON Schema as `response_format`
|
|
243
|
+
- `nativeJsonMode` → sends `response_format: json_object` + schema in system prompt
|
|
244
|
+
- Neither → schema in system prompt only
|
|
245
|
+
3. Response is parsed as JSON
|
|
246
|
+
4. Validated with `schema.safeParse()`
|
|
247
|
+
5. On failure: Zod errors are sent back to the model as a correction message
|
|
248
|
+
6. Retries up to `retries` times (default: 2)
|
|
249
|
+
7. Returns typed, validated result
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Tools
|
|
254
|
+
|
|
255
|
+
### Defining Tools
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
import { defineTool } from '@niscorp/signal';
|
|
259
|
+
import { z } from 'zod';
|
|
260
|
+
|
|
261
|
+
const searchTool = defineTool({
|
|
262
|
+
name: 'search',
|
|
263
|
+
description: 'Search for information',
|
|
264
|
+
input: z.object({
|
|
265
|
+
query: z.string().describe('Search query'),
|
|
266
|
+
}),
|
|
267
|
+
execute: async ({ query }) => {
|
|
268
|
+
const results = await mySearchApi(query);
|
|
269
|
+
return results;
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
The Zod `input` schema validates the model's arguments before `execute` runs. The return value is stringified and sent back to the model.
|
|
275
|
+
|
|
276
|
+
### Using Tools
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
const { response, meta } = await createSignal('groq')
|
|
280
|
+
.tools([searchTool])
|
|
281
|
+
.schema(z.object({ answer: z.string() }))
|
|
282
|
+
.complete('Find the current weather in Berlin');
|
|
283
|
+
|
|
284
|
+
// meta.toolCalls shows what was called
|
|
285
|
+
// response.answer contains the final answer
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### How Tool Calling Works
|
|
289
|
+
|
|
290
|
+
Signal picks a strategy based on provider capabilities:
|
|
291
|
+
|
|
292
|
+
**Native tools** (OpenAI) — Uses the provider's native `tools` parameter and `tool_calls` response. The model decides when to call tools.
|
|
293
|
+
|
|
294
|
+
**Unified schema** (Groq, others) — Tool descriptions are injected into the system prompt. The response format includes a discriminator field (`_action: "call" | "respond"`). Signal handles the loop:
|
|
295
|
+
|
|
296
|
+
1. Model responds with `_action: "call"` + tool name + args
|
|
297
|
+
2. Signal validates args with Zod, executes the tool
|
|
298
|
+
3. Tool result is sent back to the model
|
|
299
|
+
4. Model responds with `_action: "respond"` + final answer
|
|
300
|
+
5. Final answer is validated against the output schema
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Multi-Turn Conversations
|
|
305
|
+
|
|
306
|
+
Signal is stateless — it doesn't track conversation history. The caller owns history and threads it back:
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
const signal = createSignal('groq');
|
|
310
|
+
|
|
311
|
+
const r1 = await signal.complete('My name is Alice.');
|
|
312
|
+
const r2 = await signal.history(r1.history).complete('What is my name?');
|
|
313
|
+
// r2.response contains "Alice"
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
`complete()` returns `history` which includes all messages (system, user, assistant, tool calls). Pass it back via `.history()` for the next turn.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Embedding
|
|
321
|
+
|
|
322
|
+
Convert text to dense vectors for similarity search, RAG, clustering, and classification.
|
|
323
|
+
|
|
324
|
+
### Basic Usage
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
const embedder = createSignal('openai').model('text-embedding-3-small');
|
|
328
|
+
|
|
329
|
+
// Single text → single vector
|
|
330
|
+
const vector = await embedder.embed('wireless headphones');
|
|
331
|
+
// vector: number[] (1536 dimensions by default)
|
|
332
|
+
|
|
333
|
+
// Batch — single API call
|
|
334
|
+
const vectors = await embedder.embed([
|
|
335
|
+
'wireless headphones',
|
|
336
|
+
'bluetooth earbuds',
|
|
337
|
+
'industrial pump manual',
|
|
338
|
+
]);
|
|
339
|
+
// vectors: number[][] (3 vectors)
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### Dimensions
|
|
343
|
+
|
|
344
|
+
Some models support output truncation. Smaller vectors are faster to store and compare.
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
const small = await embedder.embed('text', { dimensions: 256 });
|
|
348
|
+
// small: number[256]
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Similarity
|
|
352
|
+
|
|
353
|
+
Cosine similarity between embedding vectors measures semantic relatedness:
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
const cosine = (a: number[], b: number[]) => {
|
|
357
|
+
const dot = a.reduce((s, x, i) => s + x * b[i], 0);
|
|
358
|
+
const mag = (v: number[]) => Math.sqrt(v.reduce((s, x) => s + x * x, 0));
|
|
359
|
+
return dot / (mag(a) * mag(b));
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const [a, b] = await embedder.embed([
|
|
363
|
+
'comfortable running shoes',
|
|
364
|
+
'athletic footwear for jogging',
|
|
365
|
+
]);
|
|
366
|
+
const similarity = cosine(a, b);
|
|
367
|
+
// ~0.92 — semantically similar
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Provider Support
|
|
371
|
+
|
|
372
|
+
Only providers with `supportsEmbedding: true` can embed. Currently: **OpenAI** (`text-embedding-3-small`, `text-embedding-3-large`). Calling `embed()` on a provider without support throws `E_PROVIDER_ERROR`.
|
|
373
|
+
|
|
374
|
+
Use a separate Signal client for embedding — embedding models are different from chat models:
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
const chat = createSignal('openrouter', { model: 'qwen/qwen3.8-27b' });
|
|
378
|
+
const embed = createSignal('openai', { model: 'text-embedding-3-small' });
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## Decisions
|
|
384
|
+
|
|
385
|
+
A decision model takes a state and typed questions and answers each with a pick and a probability distribution. It generates no text, so `decide()` is a sibling of `embed()`: one request, one response — no messages, no history, no tools.
|
|
386
|
+
|
|
387
|
+
### Basic Usage
|
|
388
|
+
|
|
389
|
+
```typescript
|
|
390
|
+
const jev = createSignal('typesafe');
|
|
391
|
+
|
|
392
|
+
const result = await jev.decide({
|
|
393
|
+
state: { message: 'I was charged twice for order 7429', verified: true },
|
|
394
|
+
questions: {
|
|
395
|
+
intent: {
|
|
396
|
+
type: 'choice',
|
|
397
|
+
instructions: 'What is the customer asking for?',
|
|
398
|
+
criteria: { refund: 'Money back', bug: 'A defect report', other: 'Anything else' },
|
|
399
|
+
},
|
|
400
|
+
needsHuman: { type: 'noul', instructions: 'Does this need a person?' },
|
|
401
|
+
urgency: { type: 'score', instructions: 'How urgent is it?', criteria: ['Low', 'Medium', 'High'] },
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
result.decisions.intent.choice; // 'refund' | 'bug' | 'other'
|
|
406
|
+
result.decisions.needsHuman.answer; // boolean
|
|
407
|
+
result.decisions.urgency.level; // 0 | 1 | 2 — index into criteria
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### The Questions Are the Schema
|
|
411
|
+
|
|
412
|
+
You declare the questions once and never write an output type. Signal derives both halves from them:
|
|
413
|
+
|
|
414
|
+
- **The result type.** `choice` → the union of the `criteria` keys; `score` → a level index into `criteria`; `noul` → a boolean.
|
|
415
|
+
- **The acceptance gate.** The same derivation at runtime: a choice outside the asked options, a question left unanswered, a distribution over other keys, a probability outside [0, 1] — each fails the call with `E_VALIDATION_FAILED` and the provider's body under `context.raw`.
|
|
416
|
+
|
|
417
|
+
There is no repair and no correction retry. A decision model writes no bytes to repair and cannot be told what it got wrong.
|
|
418
|
+
|
|
419
|
+
### Calibrated or Not
|
|
420
|
+
|
|
421
|
+
`result.calibrated` is the discriminant. Probabilities exist in the type only where a model produced them:
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
if (result.calibrated) {
|
|
425
|
+
result.decisions.intent.probabilities; // Record<'refund' | 'bug' | 'other', number>
|
|
426
|
+
result.decisions.intent.confidence; // 0–1
|
|
427
|
+
result.decisions.needsHuman.noul; // probability of yes
|
|
428
|
+
result.decisions.urgency.score; // probability-weighted level, e.g. 1.05
|
|
429
|
+
result.decisions.urgency.probabilities; // number[], one per level
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
On a chat provider `decide()` still works: the same questions run through structured output (native grammar where it exists, the repair ladder, correction retries) and come back as picks alone with `calibrated: false`. Signal does not invent a `1.0` — it would clear every threshold. Emulation is one to two orders of magnitude slower; a caller on a latency budget checks first:
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
if (signal.describe().kind === 'decisions') { /* sub-second */ }
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### Abort
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
const controller = new AbortController();
|
|
443
|
+
jev.decide({ state, questions, options: { signal: controller.signal } });
|
|
444
|
+
controller.abort(); // tears down the HTTP request
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### Provider Support
|
|
448
|
+
|
|
449
|
+
A provider is one of two kinds. A **decision provider** (`typesafe`) has `decide()` and nothing else — `complete`, `stream`, `step`, `stepStream` and `embed` fail with `E_VERB_NOT_SUPPORTED` before any request is built. A **chat provider** has every verb, `decide()` included, by emulation.
|
|
450
|
+
|
|
451
|
+
A decision provider by base URL — self-hosted, or the fake a check runs — names its protocol:
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
const local = createSignal({ baseUrl: 'http://127.0.0.1:8790/v1', apiKey: 'dev', model: 'fake', adapter: 'systemone' });
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
`meta.usage.reported` is `false` when the provider sent no usage, and always `false` under emulation: `complete()` sums reported and estimated usage without saying which.
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
## Error Handling
|
|
462
|
+
|
|
463
|
+
All errors are `SignalError` instances with a `.code` and optional `.context`:
|
|
464
|
+
|
|
465
|
+
| Code | When |
|
|
466
|
+
|------|------|
|
|
467
|
+
| `E_PROVIDER_NOT_FOUND` | Unknown provider string |
|
|
468
|
+
| `E_MISSING_API_KEY` | No API key found (env or explicit) |
|
|
469
|
+
| `E_MISSING_MODEL` | No model specified for custom provider |
|
|
470
|
+
| `E_MISSING_SDK` | Provider SDK not installed |
|
|
471
|
+
| `E_VALIDATION_FAILED` | Response failed Zod validation after all retries |
|
|
472
|
+
| `E_MAX_RETRIES` | Retry loop exhausted |
|
|
473
|
+
| `E_PROVIDER_ERROR` | Provider API error (rate limit, server error, etc.) |
|
|
474
|
+
| `E_TOOL_NOT_FOUND` | Model called an unknown tool |
|
|
475
|
+
| `E_TOOL_EXECUTION` | Tool execute() threw |
|
|
476
|
+
| `E_TOOL_VALIDATION` | Tool args failed Zod validation |
|
|
477
|
+
| `E_VERB_NOT_SUPPORTED` | A chat verb was called on a decision provider |
|
|
478
|
+
|
|
479
|
+
```typescript
|
|
480
|
+
import { SignalError } from '@niscorp/signal';
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
await signal.complete('...');
|
|
484
|
+
} catch (error) {
|
|
485
|
+
if (error instanceof SignalError) {
|
|
486
|
+
console.log(error.code, error.context);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
---
|
|
492
|
+
|
|
493
|
+
## Groq Specifics
|
|
494
|
+
|
|
495
|
+
Groq is fast and cheap but has quirks:
|
|
496
|
+
|
|
497
|
+
- **No native tools + structured output together.** Signal uses the unified schema strategy automatically.
|
|
498
|
+
- **`json_validate_failed` errors.** Groq sometimes returns the model's failed output in the error. Signal extracts and recovers from this.
|
|
499
|
+
- **Model-dependent capabilities.** What each Groq model does (json_schema, nested tool args, reasoning efforts) is its own row in the model registry, measured by the probe.
|
|
500
|
+
- **Tool args are validated server-side.** So `respond` params would enforce nothing on Groq, and `auto` resolves every agent there to `emit`.
|
|
501
|
+
|
|
502
|
+
You don't need to handle any of this — Signal does it automatically based on the registry.
|