@soederpop/luca 0.2.1 → 0.2.3
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/.github/workflows/release.yaml +2 -0
- package/CNAME +1 -0
- package/assistants/codingAssistant/ABOUT.md +3 -1
- package/assistants/codingAssistant/CORE.md +2 -4
- package/assistants/codingAssistant/hooks.ts +9 -10
- package/assistants/codingAssistant/tools.ts +9 -0
- package/assistants/inkbot/ABOUT.md +13 -2
- package/assistants/inkbot/CORE.md +278 -39
- package/assistants/inkbot/hooks.ts +0 -8
- package/assistants/inkbot/tools.ts +24 -18
- package/assistants/researcher/ABOUT.md +5 -0
- package/assistants/researcher/CORE.md +46 -0
- package/assistants/researcher/hooks.ts +16 -0
- package/assistants/researcher/tools.ts +237 -0
- package/commands/inkbot.ts +526 -194
- package/docs/CNAME +1 -0
- package/docs/examples/assistant-hooks-reference.ts +171 -0
- package/index.html +1430 -0
- package/package.json +1 -1
- package/public/slides-ai-native.html +902 -0
- package/public/slides-intro.html +974 -0
- package/src/agi/features/assistant.ts +432 -62
- package/src/agi/features/conversation.ts +170 -10
- package/src/bootstrap/generated.ts +1 -1
- package/src/cli/build-info.ts +2 -2
- package/src/helper.ts +12 -3
- package/src/introspection/generated.agi.ts +1663 -644
- package/src/introspection/generated.node.ts +1637 -870
- package/src/introspection/generated.web.ts +1 -1
- package/src/python/generated.ts +1 -1
- package/src/scaffolds/generated.ts +1 -1
- package/test/assistant-hooks.test.ts +306 -0
- package/test/assistant.test.ts +1 -1
- package/test/fork-and-research.test.ts +450 -0
- package/SPEC.md +0 -304
|
@@ -2,11 +2,13 @@ import { z } from 'zod'
|
|
|
2
2
|
import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
|
|
3
3
|
import { type AvailableFeatures } from '@soederpop/luca/feature'
|
|
4
4
|
import { Feature } from '../feature.js'
|
|
5
|
-
import type { Conversation, ConversationTool, ContentPart, AskOptions, Message } from './conversation'
|
|
5
|
+
import type { Conversation, ConversationTool, ContentPart, AskOptions, ForkOptions, Message } from './conversation'
|
|
6
6
|
import type { ContentDb } from '@soederpop/luca/node'
|
|
7
7
|
import type { ConversationHistory, ConversationMeta } from './conversation-history'
|
|
8
8
|
import hashObject from '../../hash-object.js'
|
|
9
9
|
import { InterceptorChain, type InterceptorFn, type InterceptorPoints, type InterceptorPoint } from '../lib/interceptor-chain.js'
|
|
10
|
+
import type { Entity } from '../../entity.js'
|
|
11
|
+
import { State } from '../../state.js'
|
|
10
12
|
|
|
11
13
|
declare module '@soederpop/luca/feature' {
|
|
12
14
|
interface AvailableFeatures {
|
|
@@ -49,6 +51,7 @@ export const AssistantStateSchema = FeatureStateSchema.extend({
|
|
|
49
51
|
pendingPlugins: z.array(z.any()).describe('Pending async plugin promises'),
|
|
50
52
|
conversation: z.any().nullable().describe('The active Conversation feature instance'),
|
|
51
53
|
subagents: z.record(z.string(), z.any()).describe('Cached subagent instances'),
|
|
54
|
+
forkDepth: z.number().describe('How many times this assistant has been forked from an ancestor. 0 = original.'),
|
|
52
55
|
})
|
|
53
56
|
|
|
54
57
|
export const AssistantOptionsSchema = FeatureOptionsSchema.extend({
|
|
@@ -107,11 +110,55 @@ export const AssistantOptionsSchema = FeatureOptionsSchema.extend({
|
|
|
107
110
|
|
|
108
111
|
/** Convenience alias for allowTools — an explicit list of tool names (exact matches only). */
|
|
109
112
|
toolNames: z.array(z.string()).optional().describe('Explicit list of tool names to include (exact match). Shorthand for allowTools without glob patterns.'),
|
|
113
|
+
|
|
114
|
+
/** Options passed through to the underlying OpenAI client (e.g. baseURL, apiKey). */
|
|
115
|
+
clientOptions: z.record(z.string(), z.any()).optional().describe('Options for the OpenAI client, passed through to the conversation'),
|
|
110
116
|
})
|
|
111
117
|
|
|
112
118
|
export type AssistantState = z.infer<typeof AssistantStateSchema>
|
|
113
119
|
export type AssistantOptions = z.infer<typeof AssistantOptionsSchema>
|
|
114
120
|
|
|
121
|
+
/** Fork options extended with assistant-specific tool filtering and lifecycle hooks. */
|
|
122
|
+
export type AssistantForkOptions = ForkOptions & {
|
|
123
|
+
/** Denylist of tool name patterns to exclude from the fork. Supports "*" glob matching. */
|
|
124
|
+
forbidTools?: string[]
|
|
125
|
+
/** Strict allowlist of tool name patterns for the fork. Supports "*" glob matching. */
|
|
126
|
+
allowTools?: string[]
|
|
127
|
+
/** Explicit list of tool names to include in the fork (exact match). */
|
|
128
|
+
toolNames?: string[]
|
|
129
|
+
/**
|
|
130
|
+
* Called with the forked assistant after it has been fully initialized (started, interceptors cloned,
|
|
131
|
+
* system prompt extensions copied, forkDepth set). Use this to add/remove tools, tweak state,
|
|
132
|
+
* inject system prompt extensions, or anything else before the fork is used.
|
|
133
|
+
*/
|
|
134
|
+
onFork?: (fork: Assistant, parent: Assistant) => void | Promise<void>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface ResearchJobState {
|
|
138
|
+
status: 'running' | 'completed' | 'failed'
|
|
139
|
+
prompt: string
|
|
140
|
+
questions: string[]
|
|
141
|
+
results: (string | null)[]
|
|
142
|
+
errors: (string | null)[]
|
|
143
|
+
completed: number
|
|
144
|
+
total: number
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ResearchJobOptions {
|
|
148
|
+
prompt: string
|
|
149
|
+
questions: string[]
|
|
150
|
+
forkOptions: AssistantForkOptions
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type ResearchJobEvents = {
|
|
154
|
+
forkCompleted: [number, string]
|
|
155
|
+
forkError: [number, string]
|
|
156
|
+
completed: [string[]]
|
|
157
|
+
failed: [(string | null)[]]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type ResearchJob = Entity<ResearchJobState, ResearchJobOptions, ResearchJobEvents>
|
|
161
|
+
|
|
115
162
|
/**
|
|
116
163
|
* An Assistant is a combination of a system prompt and tool calls that has a
|
|
117
164
|
* conversation with an LLM. You define an assistant by creating a folder with
|
|
@@ -143,6 +190,22 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
143
190
|
beforeResponse: new InterceptorChain<InterceptorPoints['beforeResponse']>(),
|
|
144
191
|
}
|
|
145
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Extension point for plugins, setupToolsConsumer, and hooks to attach
|
|
195
|
+
* arbitrary methods to the assistant instance (e.g. voice-mode adding
|
|
196
|
+
* mute/unmute). Access via `assistant.ext.myMethod()`.
|
|
197
|
+
*/
|
|
198
|
+
readonly ext: Record<string, (...args: any[]) => any> = {}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Observable runtime state that the assistant can manipulate freely via
|
|
202
|
+
* tool calls, hooks, or extensions. Unlike the feature's own `state`
|
|
203
|
+
* (which tracks internal lifecycle), mentalState is a blank slate for
|
|
204
|
+
* the assistant's own use — tracking mood, goals, context, preferences,
|
|
205
|
+
* or anything else. Fully observable so UI or other systems can react.
|
|
206
|
+
*/
|
|
207
|
+
readonly mentalState = new State<Record<string, any>>()
|
|
208
|
+
|
|
146
209
|
/**
|
|
147
210
|
* Register an interceptor at a given point in the pipeline.
|
|
148
211
|
*
|
|
@@ -159,6 +222,26 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
159
222
|
return this
|
|
160
223
|
}
|
|
161
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Trigger a named hook and await its completion. The hook function receives
|
|
227
|
+
* `(assistant, ...args)` and its return value is passed back to the caller.
|
|
228
|
+
* This ensures hooks run to completion BEFORE any subsequent logic executes,
|
|
229
|
+
* unlike the old bus-based approach where async hooks were fire-and-forget.
|
|
230
|
+
*
|
|
231
|
+
* Hooks that don't exist are silently skipped (returns undefined).
|
|
232
|
+
*
|
|
233
|
+
* @param hookName - The hook to trigger (matches an export name from hooks.ts)
|
|
234
|
+
* @param args - Arguments passed to the hook after the assistant instance
|
|
235
|
+
* @returns The hook's return value, or undefined if no hook exists
|
|
236
|
+
*/
|
|
237
|
+
async triggerHook(hookName: string, ...args: any[]): Promise<any> {
|
|
238
|
+
const hooks = (this.state.get('hooks') || {}) as Record<string, (...args: any[]) => any>
|
|
239
|
+
const hookFn = hooks[hookName]
|
|
240
|
+
if (!hookFn) return undefined
|
|
241
|
+
this.emit('hookFired', hookName)
|
|
242
|
+
return await hookFn(this, ...args)
|
|
243
|
+
}
|
|
244
|
+
|
|
162
245
|
/** @returns Default state with the assistant not started, zero conversations, and the resolved folder path. */
|
|
163
246
|
override get initialState(): AssistantState {
|
|
164
247
|
return {
|
|
@@ -245,8 +328,8 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
245
328
|
|
|
246
329
|
/**
|
|
247
330
|
* Called immediately after the assistant is constructed. Synchronously loads
|
|
248
|
-
* the system prompt, tools, and hooks
|
|
249
|
-
*
|
|
331
|
+
* the system prompt, tools, and hooks. Hooks are invoked via triggerHook()
|
|
332
|
+
* at each emit site, ensuring async hooks are properly awaited.
|
|
250
333
|
*/
|
|
251
334
|
override afterInitialize() {
|
|
252
335
|
this.state.set('pendingPlugins', [])
|
|
@@ -258,10 +341,11 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
258
341
|
this.state.set('tools', this.loadTools())
|
|
259
342
|
this.state.set('hooks', this.loadHooks())
|
|
260
343
|
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
344
|
+
// Defer created hook+event so external listeners can register first
|
|
345
|
+
setTimeout(async () => {
|
|
346
|
+
await this.triggerHook('created')
|
|
347
|
+
this.emit('created')
|
|
348
|
+
}, 1)
|
|
265
349
|
}
|
|
266
350
|
|
|
267
351
|
get conversation(): Conversation {
|
|
@@ -279,6 +363,7 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
279
363
|
...(this.effectiveOptions.frequencyPenalty != null ? { frequencyPenalty: this.effectiveOptions.frequencyPenalty } : {}),
|
|
280
364
|
...(this.effectiveOptions.presencePenalty != null ? { presencePenalty: this.effectiveOptions.presencePenalty } : {}),
|
|
281
365
|
...(this.effectiveOptions.stop ? { stop: this.effectiveOptions.stop } : {}),
|
|
366
|
+
...(this.effectiveOptions.clientOptions ? { clientOptions: this.effectiveOptions.clientOptions } : {}),
|
|
282
367
|
history: [
|
|
283
368
|
{ role: 'system', content: this.effectiveSystemPrompt },
|
|
284
369
|
],
|
|
@@ -301,6 +386,16 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
301
386
|
return !!this.state.get('started')
|
|
302
387
|
}
|
|
303
388
|
|
|
389
|
+
/** Whether this assistant was created via fork(). */
|
|
390
|
+
get isFork(): boolean {
|
|
391
|
+
return (this.state.get('forkDepth') ?? 0) > 0
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** How many levels deep this fork is. 0 = original, 1 = direct fork, 2 = fork of a fork, etc. */
|
|
395
|
+
get forkDepth(): number {
|
|
396
|
+
return (this.state.get('forkDepth') as number) ?? 0
|
|
397
|
+
}
|
|
398
|
+
|
|
304
399
|
/** The current system prompt text. */
|
|
305
400
|
get systemPrompt(): string {
|
|
306
401
|
return this.state.get('systemPrompt') || ''
|
|
@@ -451,6 +546,9 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
451
546
|
}
|
|
452
547
|
} else if (fnOrHelper && 'schemas' in fnOrHelper && 'handlers' in fnOrHelper) {
|
|
453
548
|
this._registerTools(fnOrHelper as { schemas: Record<string, z.ZodType>, handlers: Record<string, Function> })
|
|
549
|
+
if (typeof (fnOrHelper as any).setup === 'function') {
|
|
550
|
+
(fnOrHelper as any).setup(this)
|
|
551
|
+
}
|
|
454
552
|
}
|
|
455
553
|
return this
|
|
456
554
|
}
|
|
@@ -704,11 +802,17 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
704
802
|
return this.mergeOptionTools(tools)
|
|
705
803
|
}
|
|
706
804
|
|
|
805
|
+
// Stash `export const use = [...]` for deferred processing during start(),
|
|
806
|
+
// since the assistant isn't fully constructed yet when loadTools() runs
|
|
807
|
+
if (Array.isArray(moduleExports.use)) {
|
|
808
|
+
this.state.set('deferredUse', moduleExports.use)
|
|
809
|
+
}
|
|
810
|
+
|
|
707
811
|
if (Object.keys(moduleExports).length) {
|
|
708
812
|
const schemas: Record<string, z.ZodType> = moduleExports.schemas || {}
|
|
709
813
|
|
|
710
814
|
for (const [name, fn] of Object.entries(moduleExports)) {
|
|
711
|
-
if (name === 'schemas' || name === 'default' || typeof fn !== 'function') continue
|
|
815
|
+
if (name === 'schemas' || name === 'default' || name === 'use' || typeof fn !== 'function') continue
|
|
712
816
|
|
|
713
817
|
const schema = schemas[name]
|
|
714
818
|
if (schema) {
|
|
@@ -963,41 +1067,9 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
963
1067
|
}
|
|
964
1068
|
}
|
|
965
1069
|
|
|
966
|
-
/**
|
|
967
|
-
* Bind all loaded hook functions as event listeners. Each hook whose
|
|
968
|
-
* name matches an event gets wired up so it fires automatically when
|
|
969
|
-
* that event is emitted. Must be called before any events are emitted.
|
|
970
|
-
*/
|
|
971
|
-
/** Hook names that are called directly during lifecycle, not bound as event listeners. */
|
|
972
|
-
private static lifecycleHooks = new Set(['formatSystemPrompt'])
|
|
973
|
-
/** Stored references to bound hook listeners so they can be unbound on reload. Lazily initialized because afterInitialize runs before field initializers. */
|
|
974
|
-
private _boundHookListeners!: Array<{ event: string; listener: (...args: any[]) => void }>
|
|
975
1070
|
/** Tool names added at runtime via addTool()/use(), so reload() can preserve them. */
|
|
976
1071
|
private _runtimeToolNames!: Set<string>
|
|
977
1072
|
|
|
978
|
-
private bindHooksToEvents() {
|
|
979
|
-
if (!this._boundHookListeners) this._boundHookListeners = []
|
|
980
|
-
const assistant = this
|
|
981
|
-
const hooks = (this.state.get('hooks') || {}) as Record<string, (...args: any[]) => any>
|
|
982
|
-
for (const [eventName, hookFn] of Object.entries(hooks)) {
|
|
983
|
-
if (Assistant.lifecycleHooks.has(eventName)) continue
|
|
984
|
-
const listener = (...args: any[]) => {
|
|
985
|
-
this.emit('hookFired', eventName)
|
|
986
|
-
hookFn(assistant, ...args)
|
|
987
|
-
}
|
|
988
|
-
this._boundHookListeners.push({ event: eventName, listener })
|
|
989
|
-
this.on(eventName as any, listener)
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
private unbindHooksFromEvents() {
|
|
994
|
-
if (!this._boundHookListeners) return
|
|
995
|
-
for (const { event, listener } of this._boundHookListeners) {
|
|
996
|
-
this.off(event as any, listener)
|
|
997
|
-
}
|
|
998
|
-
this._boundHookListeners = []
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
1073
|
/**
|
|
1002
1074
|
* Reload tools, hooks, and system prompt from disk. Useful during development
|
|
1003
1075
|
* or when tool/hook files have been modified and you want the assistant to
|
|
@@ -1006,9 +1078,6 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
1006
1078
|
* @returns this, for chaining
|
|
1007
1079
|
*/
|
|
1008
1080
|
reload(): this {
|
|
1009
|
-
// Unbind existing hook listeners
|
|
1010
|
-
this.unbindHooksFromEvents()
|
|
1011
|
-
|
|
1012
1081
|
// Snapshot runtime-added tools before reloading from disk
|
|
1013
1082
|
const runtimeTools: Record<string, ConversationTool> = {}
|
|
1014
1083
|
if (this._runtimeToolNames?.size) {
|
|
@@ -1026,9 +1095,8 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
1026
1095
|
this.state.set('tools', { ...diskTools, ...runtimeTools })
|
|
1027
1096
|
this.emit('toolsChanged')
|
|
1028
1097
|
|
|
1029
|
-
// Reload hooks from disk
|
|
1098
|
+
// Reload hooks from disk — triggerHook reads from state so new hooks are active immediately
|
|
1030
1099
|
this.state.set('hooks', this.loadHooks())
|
|
1031
|
-
this.bindHooksToEvents()
|
|
1032
1100
|
|
|
1033
1101
|
this.emit('reloaded')
|
|
1034
1102
|
|
|
@@ -1046,6 +1114,19 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
1046
1114
|
// Prevent duplicate listener registration if already started
|
|
1047
1115
|
if (this.isStarted) return this
|
|
1048
1116
|
|
|
1117
|
+
// Process deferred `use` entries from tools.ts (stashed during loadTools
|
|
1118
|
+
// because the assistant isn't fully constructed at that point)
|
|
1119
|
+
const deferredUse = this.state.get('deferredUse') as any[] | undefined
|
|
1120
|
+
if (deferredUse?.length) {
|
|
1121
|
+
for (const entry of deferredUse) {
|
|
1122
|
+
this.use(entry)
|
|
1123
|
+
}
|
|
1124
|
+
this.state.set('deferredUse', undefined)
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Allow hooks to run before the assistant starts (blocks until complete)
|
|
1128
|
+
await this.triggerHook('beforeStart')
|
|
1129
|
+
|
|
1049
1130
|
// Wait for any async .use() plugins to finish before starting
|
|
1050
1131
|
const pending = this.state.get('pendingPlugins') as Promise<void>[]
|
|
1051
1132
|
if (pending.length) {
|
|
@@ -1055,43 +1136,72 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
1055
1136
|
|
|
1056
1137
|
// Allow hooks.ts to export a formatSystemPrompt(assistant, prompt) => string
|
|
1057
1138
|
// that transforms the system prompt before the conversation is created.
|
|
1058
|
-
const
|
|
1059
|
-
if (
|
|
1060
|
-
|
|
1061
|
-
if (typeof result === 'string') {
|
|
1062
|
-
this.state.set('systemPrompt', result)
|
|
1063
|
-
}
|
|
1139
|
+
const formatted = await this.triggerHook('formatSystemPrompt', this.systemPrompt)
|
|
1140
|
+
if (typeof formatted === 'string') {
|
|
1141
|
+
this.state.set('systemPrompt', formatted)
|
|
1064
1142
|
}
|
|
1065
1143
|
|
|
1066
1144
|
// Wire up event forwarding from conversation to assistant.
|
|
1067
|
-
//
|
|
1068
|
-
this.conversation.on('turnStart', (info: any) =>
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
this.conversation.on('
|
|
1145
|
+
// Each forwarded event triggers its hook (awaited) before emitting on the assistant bus.
|
|
1146
|
+
this.conversation.on('turnStart', async (info: any) => {
|
|
1147
|
+
await this.triggerHook('turnStart', info)
|
|
1148
|
+
this.emit('turnStart', info)
|
|
1149
|
+
})
|
|
1150
|
+
this.conversation.on('turnEnd', async (info: any) => {
|
|
1151
|
+
await this.triggerHook('turnEnd', info)
|
|
1152
|
+
this.emit('turnEnd', info)
|
|
1153
|
+
})
|
|
1154
|
+
this.conversation.on('chunk', async (chunk: string) => {
|
|
1155
|
+
await this.triggerHook('chunk', chunk)
|
|
1156
|
+
this.emit('chunk', chunk)
|
|
1157
|
+
})
|
|
1158
|
+
this.conversation.on('preview', async (text: string) => {
|
|
1159
|
+
await this.triggerHook('preview', text)
|
|
1160
|
+
this.emit('preview', text)
|
|
1161
|
+
})
|
|
1162
|
+
this.conversation.on('response', async (text: string) => {
|
|
1163
|
+
await this.triggerHook('response', text)
|
|
1073
1164
|
this.emit('response', text)
|
|
1074
1165
|
this.state.set('lastResponse', text)
|
|
1075
1166
|
})
|
|
1076
|
-
this.conversation.on('rawEvent', (event: any) =>
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
this.conversation.on('
|
|
1167
|
+
this.conversation.on('rawEvent', async (event: any) => {
|
|
1168
|
+
await this.triggerHook('rawEvent', event)
|
|
1169
|
+
this.emit('rawEvent', event)
|
|
1170
|
+
})
|
|
1171
|
+
this.conversation.on('mcpEvent', async (event: any) => {
|
|
1172
|
+
await this.triggerHook('mcpEvent', event)
|
|
1173
|
+
this.emit('mcpEvent', event)
|
|
1174
|
+
})
|
|
1175
|
+
this.conversation.on('toolCall', async (name: string, args: any) => {
|
|
1176
|
+
await this.triggerHook('toolCall', name, args)
|
|
1177
|
+
this.emit('toolCall', name, args)
|
|
1178
|
+
})
|
|
1179
|
+
this.conversation.on('toolResult', async (name: string, result: any) => {
|
|
1180
|
+
await this.triggerHook('toolResult', name, result)
|
|
1181
|
+
this.emit('toolResult', name, result)
|
|
1182
|
+
})
|
|
1183
|
+
this.conversation.on('toolError', async (name: string, error: any) => {
|
|
1184
|
+
await this.triggerHook('toolError', name, error)
|
|
1185
|
+
this.emit('toolError', name, error)
|
|
1186
|
+
})
|
|
1081
1187
|
|
|
1082
1188
|
// Install interceptor-aware tool executor on the conversation
|
|
1083
1189
|
this.conversation.toolExecutor = async (name: string, args: Record<string, any>, handler: (...a: any[]) => Promise<any>) => {
|
|
1084
1190
|
const ctx = { name, args, result: undefined as string | undefined, error: undefined, skip: false }
|
|
1085
1191
|
|
|
1192
|
+
// Hook runs first (awaited), then interceptor chain
|
|
1193
|
+
await this.triggerHook('beforeToolCall', ctx)
|
|
1086
1194
|
await this.interceptors.beforeToolCall.run(ctx, async () => {})
|
|
1087
1195
|
|
|
1088
1196
|
if (ctx.skip) {
|
|
1089
1197
|
const result = ctx.result ?? JSON.stringify({ skipped: true })
|
|
1198
|
+
await this.triggerHook('toolResult', ctx.name, result)
|
|
1090
1199
|
this.emit('toolResult', ctx.name, result)
|
|
1091
1200
|
return result
|
|
1092
1201
|
}
|
|
1093
1202
|
|
|
1094
1203
|
try {
|
|
1204
|
+
await this.triggerHook('toolCall', ctx.name, ctx.args)
|
|
1095
1205
|
this.emit('toolCall', ctx.name, ctx.args)
|
|
1096
1206
|
const output = await handler(ctx.args)
|
|
1097
1207
|
ctx.result = typeof output === 'string' ? output : JSON.stringify(output)
|
|
@@ -1100,11 +1210,15 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
1100
1210
|
ctx.result = JSON.stringify({ error: err.message || String(err) })
|
|
1101
1211
|
}
|
|
1102
1212
|
|
|
1213
|
+
// Hook runs first (awaited), then interceptor chain
|
|
1214
|
+
await this.triggerHook('afterToolCall', ctx)
|
|
1103
1215
|
await this.interceptors.afterToolCall.run(ctx, async () => {})
|
|
1104
1216
|
|
|
1105
1217
|
if (ctx.error && !ctx.result?.includes('"error"')) {
|
|
1218
|
+
await this.triggerHook('toolError', ctx.name, ctx.error)
|
|
1106
1219
|
this.emit('toolError', ctx.name, ctx.error)
|
|
1107
1220
|
} else {
|
|
1221
|
+
await this.triggerHook('toolResult', ctx.name, ctx.result!)
|
|
1108
1222
|
this.emit('toolResult', ctx.name, ctx.result!)
|
|
1109
1223
|
}
|
|
1110
1224
|
|
|
@@ -1128,8 +1242,12 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
1128
1242
|
})
|
|
1129
1243
|
|
|
1130
1244
|
this.state.set('started', true)
|
|
1245
|
+
await this.triggerHook('started')
|
|
1131
1246
|
this.emit('started')
|
|
1132
1247
|
|
|
1248
|
+
// afterStart blocks until complete — use for setup that needs the full assistant ready
|
|
1249
|
+
await this.triggerHook('afterStart')
|
|
1250
|
+
|
|
1133
1251
|
return this
|
|
1134
1252
|
}
|
|
1135
1253
|
|
|
@@ -1161,6 +1279,17 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
1161
1279
|
question = this.prependTimestamp(question)
|
|
1162
1280
|
}
|
|
1163
1281
|
|
|
1282
|
+
// Trigger beforeInitialAsk only on the first ask() call
|
|
1283
|
+
if (count === 1) {
|
|
1284
|
+
await this.triggerHook('beforeInitialAsk', question, options)
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Trigger beforeAsk hook on every ask() call — can modify question via return value
|
|
1288
|
+
const hookResult = await this.triggerHook('beforeAsk', question, options)
|
|
1289
|
+
if (typeof hookResult === 'string') {
|
|
1290
|
+
question = hookResult
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1164
1293
|
// Run beforeAsk interceptors — they can rewrite the question or short-circuit
|
|
1165
1294
|
if (this.interceptors.beforeAsk.hasInterceptors) {
|
|
1166
1295
|
const ctx = { question, options } as InterceptorPoints['beforeAsk']
|
|
@@ -1184,6 +1313,7 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
1184
1313
|
await this.conversation.save({ thread: this.state.get('threadId') })
|
|
1185
1314
|
}
|
|
1186
1315
|
|
|
1316
|
+
await this.triggerHook('answered', result)
|
|
1187
1317
|
this.emit('answered', result)
|
|
1188
1318
|
|
|
1189
1319
|
return result
|
|
@@ -1203,6 +1333,246 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
|
|
|
1203
1333
|
return this.conversation.save(opts)
|
|
1204
1334
|
}
|
|
1205
1335
|
|
|
1336
|
+
// -- Fork & Research API --
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Fork the assistant into a new independent instance. The fork gets its own
|
|
1340
|
+
* conversation (with configurable history truncation) but preserves the
|
|
1341
|
+
* assistant's full identity: interceptors, tools, hooks, system prompt extensions.
|
|
1342
|
+
*
|
|
1343
|
+
* @param options - Fork options including history truncation and conversation overrides
|
|
1344
|
+
* - `history: 'full'` (default) — deep copy all messages
|
|
1345
|
+
* - `history: 'none'` — system prompt only
|
|
1346
|
+
* - `history: number` — keep last N exchanges + system prompt
|
|
1347
|
+
* - Plus any conversation creation overrides (model, maxTokens, temperature, etc.)
|
|
1348
|
+
*
|
|
1349
|
+
* When called with an array, creates multiple independent forks.
|
|
1350
|
+
*
|
|
1351
|
+
* @example
|
|
1352
|
+
* ```typescript
|
|
1353
|
+
* // Single fork with no history, cheap model
|
|
1354
|
+
* const fork = await assistant.fork({ history: 'none', model: 'gpt-4o-mini' })
|
|
1355
|
+
* const answer = await fork.ask('Quick factual question')
|
|
1356
|
+
*
|
|
1357
|
+
* // Multiple forks
|
|
1358
|
+
* const [a, b] = await assistant.fork([
|
|
1359
|
+
* { history: 'none' },
|
|
1360
|
+
* { history: 3 },
|
|
1361
|
+
* ])
|
|
1362
|
+
* ```
|
|
1363
|
+
*/
|
|
1364
|
+
async fork(options?: AssistantForkOptions): Promise<Assistant>
|
|
1365
|
+
async fork(options?: AssistantForkOptions[]): Promise<Assistant[]>
|
|
1366
|
+
async fork(options: AssistantForkOptions | AssistantForkOptions[] = {}): Promise<Assistant | Assistant[]> {
|
|
1367
|
+
if (Array.isArray(options)) {
|
|
1368
|
+
return Promise.all(options.map(o => this.fork(o)))
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
if (!this.isStarted) {
|
|
1372
|
+
await this.start()
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Separate assistant-level options from conversation-level options
|
|
1376
|
+
const { history: historyMode, forbidTools, allowTools, toolNames, onFork, ...convOverrides } = options
|
|
1377
|
+
|
|
1378
|
+
// Fork the conversation with history truncation
|
|
1379
|
+
const forkedConv = this.conversation.fork({ history: historyMode ?? 'full', ...convOverrides })
|
|
1380
|
+
|
|
1381
|
+
// Create a new assistant that reuses the forked conversation
|
|
1382
|
+
const forkedAssistant = this.container.feature('assistant', {
|
|
1383
|
+
...this.options,
|
|
1384
|
+
// Pass through conversation overrides that map to assistant options
|
|
1385
|
+
...(convOverrides.model ? { model: convOverrides.model } : {}),
|
|
1386
|
+
...(convOverrides.maxTokens ? { maxTokens: convOverrides.maxTokens } : {}),
|
|
1387
|
+
...(convOverrides.temperature != null ? { temperature: convOverrides.temperature } : {}),
|
|
1388
|
+
...(convOverrides.topP != null ? { topP: convOverrides.topP } : {}),
|
|
1389
|
+
...(convOverrides.topK != null ? { topK: convOverrides.topK } : {}),
|
|
1390
|
+
...(convOverrides.frequencyPenalty != null ? { frequencyPenalty: convOverrides.frequencyPenalty } : {}),
|
|
1391
|
+
...(convOverrides.presencePenalty != null ? { presencePenalty: convOverrides.presencePenalty } : {}),
|
|
1392
|
+
...(convOverrides.stop ? { stop: convOverrides.stop } : {}),
|
|
1393
|
+
// Pass through tool filtering options
|
|
1394
|
+
...(forbidTools ? { forbidTools } : {}),
|
|
1395
|
+
...(allowTools ? { allowTools } : {}),
|
|
1396
|
+
...(toolNames ? { toolNames } : {}),
|
|
1397
|
+
}) as Assistant
|
|
1398
|
+
|
|
1399
|
+
// Inject the forked conversation directly, bypassing the lazy getter
|
|
1400
|
+
forkedAssistant.state.set('conversation', forkedConv)
|
|
1401
|
+
|
|
1402
|
+
// Track fork depth so forks know they are forks
|
|
1403
|
+
forkedAssistant.state.set('forkDepth', this.forkDepth + 1)
|
|
1404
|
+
|
|
1405
|
+
// Clone interceptors so the fork behaves like the original
|
|
1406
|
+
forkedAssistant.interceptors.beforeAsk = this.interceptors.beforeAsk.clone()
|
|
1407
|
+
forkedAssistant.interceptors.beforeTurn = this.interceptors.beforeTurn.clone()
|
|
1408
|
+
forkedAssistant.interceptors.beforeToolCall = this.interceptors.beforeToolCall.clone()
|
|
1409
|
+
forkedAssistant.interceptors.afterToolCall = this.interceptors.afterToolCall.clone()
|
|
1410
|
+
forkedAssistant.interceptors.beforeResponse = this.interceptors.beforeResponse.clone()
|
|
1411
|
+
|
|
1412
|
+
// Copy system prompt extensions
|
|
1413
|
+
forkedAssistant.state.set('systemPromptExtensions', { ...this.systemPromptExtensions })
|
|
1414
|
+
|
|
1415
|
+
// Start wires up event forwarding and the interceptor-aware tool executor
|
|
1416
|
+
await forkedAssistant.start()
|
|
1417
|
+
|
|
1418
|
+
// Call the onFork hook if provided — lets callers customize the fork before use
|
|
1419
|
+
if (onFork) {
|
|
1420
|
+
await onFork(forkedAssistant, this)
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
return forkedAssistant
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/** Active and completed research jobs, keyed by job entity ID. */
|
|
1427
|
+
readonly researchJobs = new Map<string, ResearchJob>()
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Create a non-blocking research job that fans out questions across forked assistants.
|
|
1431
|
+
* The forks fire immediately and the returned entity tracks progress via observable
|
|
1432
|
+
* state and events. Each fork preserves the full assistant identity (interceptors,
|
|
1433
|
+
* tools, hooks).
|
|
1434
|
+
*
|
|
1435
|
+
* @param prompt - Shared context/framing prompt prepended to each fork's system prompt
|
|
1436
|
+
* @param questions - Array of questions (strings) or objects with question + per-fork overrides
|
|
1437
|
+
* @param defaults - Default fork options applied to all forks
|
|
1438
|
+
* @returns A research job entity with observable state and events
|
|
1439
|
+
*
|
|
1440
|
+
* @example
|
|
1441
|
+
* ```typescript
|
|
1442
|
+
* // Fire and forget — check later
|
|
1443
|
+
* const job = await assistant.createResearchJob(
|
|
1444
|
+
* "Analyze this codebase for security issues",
|
|
1445
|
+
* ["Look for SQL injection", "Look for XSS", "Look for auth bypass"],
|
|
1446
|
+
* { history: 'none', model: 'gpt-4o-mini' }
|
|
1447
|
+
* )
|
|
1448
|
+
*
|
|
1449
|
+
* // Check progress
|
|
1450
|
+
* job.state.get('completed') // 2 of 3
|
|
1451
|
+
* job.state.get('results') // [answer1, answer2, null]
|
|
1452
|
+
*
|
|
1453
|
+
* // React to events
|
|
1454
|
+
* job.on('forkCompleted', (index, result) => console.log(`Fork ${index} done`))
|
|
1455
|
+
*
|
|
1456
|
+
* // Or just wait
|
|
1457
|
+
* await job.waitFor('completed')
|
|
1458
|
+
* ```
|
|
1459
|
+
*/
|
|
1460
|
+
async createResearchJob(
|
|
1461
|
+
prompt: string,
|
|
1462
|
+
questions: (string | { question: string; forkOptions?: AssistantForkOptions })[],
|
|
1463
|
+
defaults: AssistantForkOptions = {}
|
|
1464
|
+
): Promise<ResearchJob> {
|
|
1465
|
+
if (!this.isStarted) {
|
|
1466
|
+
await this.start()
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const jobId = `research:${this.container.utils.uuid()}`
|
|
1470
|
+
const total = questions.length
|
|
1471
|
+
|
|
1472
|
+
const job = this.container.entity<ResearchJobState, ResearchJobOptions, ResearchJobEvents>(
|
|
1473
|
+
jobId,
|
|
1474
|
+
{ prompt, questions: questions.map(q => typeof q === 'string' ? q : q.question), forkOptions: defaults },
|
|
1475
|
+
) as ResearchJob
|
|
1476
|
+
|
|
1477
|
+
job.setState({
|
|
1478
|
+
status: 'running',
|
|
1479
|
+
prompt,
|
|
1480
|
+
questions: questions.map(q => typeof q === 'string' ? q : q.question),
|
|
1481
|
+
results: new Array(total).fill(null),
|
|
1482
|
+
errors: new Array(total).fill(null),
|
|
1483
|
+
completed: 0,
|
|
1484
|
+
total,
|
|
1485
|
+
})
|
|
1486
|
+
|
|
1487
|
+
this.researchJobs.set(jobId, job)
|
|
1488
|
+
|
|
1489
|
+
// Build fork configs and create forks
|
|
1490
|
+
const forkConfigs = questions.map(q => ({
|
|
1491
|
+
...defaults,
|
|
1492
|
+
...(typeof q === 'string' ? {} : q.forkOptions),
|
|
1493
|
+
}))
|
|
1494
|
+
|
|
1495
|
+
const forks = await this.fork(forkConfigs)
|
|
1496
|
+
|
|
1497
|
+
// Apply shared prompt as a system prompt extension on each fork
|
|
1498
|
+
if (prompt) {
|
|
1499
|
+
for (const fork of forks) {
|
|
1500
|
+
fork.addSystemPromptExtension('researchPrompt', prompt)
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// Fire all forks — don't await the batch, let them resolve individually
|
|
1505
|
+
for (let i = 0; i < forks.length; i++) {
|
|
1506
|
+
const fork = forks[i]!
|
|
1507
|
+
const q = questions[i]!
|
|
1508
|
+
const question = typeof q === 'string' ? q : q.question
|
|
1509
|
+
|
|
1510
|
+
fork.ask(question).then(
|
|
1511
|
+
(result) => {
|
|
1512
|
+
const results = [...job.state.get('results')!]
|
|
1513
|
+
results[i] = result
|
|
1514
|
+
const completed = job.state.get('completed')! + 1
|
|
1515
|
+
|
|
1516
|
+
job.setState({ results, completed })
|
|
1517
|
+
job.emit('forkCompleted', i, result)
|
|
1518
|
+
|
|
1519
|
+
if (completed === total) {
|
|
1520
|
+
job.setState({ status: 'completed' })
|
|
1521
|
+
job.emit('completed', results as string[])
|
|
1522
|
+
}
|
|
1523
|
+
},
|
|
1524
|
+
(err) => {
|
|
1525
|
+
const errors = [...job.state.get('errors')!]
|
|
1526
|
+
errors[i] = err?.message || String(err)
|
|
1527
|
+
const completed = job.state.get('completed')! + 1
|
|
1528
|
+
|
|
1529
|
+
job.setState({ errors, completed })
|
|
1530
|
+
job.emit('forkError', i, errors[i]!)
|
|
1531
|
+
|
|
1532
|
+
if (completed === total) {
|
|
1533
|
+
const results = job.state.get('results')!
|
|
1534
|
+
const hasAnyResult = results.some(r => r !== null)
|
|
1535
|
+
job.setState({ status: hasAnyResult ? 'completed' : 'failed' })
|
|
1536
|
+
|
|
1537
|
+
if (hasAnyResult) {
|
|
1538
|
+
job.emit('completed', results as string[])
|
|
1539
|
+
} else {
|
|
1540
|
+
job.emit('failed', errors)
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
)
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
return job
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Fan out N questions in parallel using forked assistants, return the results.
|
|
1552
|
+
* Sugar over createResearchJob — blocks until all forks complete.
|
|
1553
|
+
*
|
|
1554
|
+
* @param questions - Array of questions (strings) or objects with question + per-fork overrides
|
|
1555
|
+
* @param defaults - Default fork options applied to all forks
|
|
1556
|
+
* @returns Array of response strings, one per question
|
|
1557
|
+
*
|
|
1558
|
+
* @example
|
|
1559
|
+
* ```typescript
|
|
1560
|
+
* const results = await assistant.research([
|
|
1561
|
+
* "What are best practices for X?",
|
|
1562
|
+
* "What are common pitfalls of X?",
|
|
1563
|
+
* ], { history: 'none', model: 'gpt-4o-mini' })
|
|
1564
|
+
* ```
|
|
1565
|
+
*/
|
|
1566
|
+
async research(
|
|
1567
|
+
questions: (string | { question: string; forkOptions?: AssistantForkOptions })[],
|
|
1568
|
+
defaults: AssistantForkOptions & { prompt?: string } = {}
|
|
1569
|
+
): Promise<(string | null)[]> {
|
|
1570
|
+
const { prompt = '', ...forkDefaults } = defaults
|
|
1571
|
+
const job = await this.createResearchJob(prompt, questions, forkDefaults)
|
|
1572
|
+
await job.waitFor('completed')
|
|
1573
|
+
return job.state.get('results')!
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1206
1576
|
// -- Subagent API --
|
|
1207
1577
|
|
|
1208
1578
|
/**
|