@soederpop/luca 0.0.28 → 0.0.30

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.
Files changed (51) hide show
  1. package/commands/try-all-challenges.ts +1 -1
  2. package/docs/TABLE-OF-CONTENTS.md +0 -3
  3. package/docs/examples/structured-output-with-assistants.md +144 -0
  4. package/docs/tutorials/20-browser-esm.md +234 -0
  5. package/package.json +1 -1
  6. package/src/agi/container.server.ts +4 -0
  7. package/src/agi/features/assistant.ts +132 -2
  8. package/src/agi/features/browser-use.ts +623 -0
  9. package/src/agi/features/conversation.ts +135 -45
  10. package/src/agi/lib/interceptor-chain.ts +79 -0
  11. package/src/bootstrap/generated.ts +381 -308
  12. package/src/cli/build-info.ts +2 -2
  13. package/src/clients/rest.ts +7 -7
  14. package/src/commands/chat.ts +22 -0
  15. package/src/commands/describe.ts +67 -2
  16. package/src/commands/prompt.ts +23 -3
  17. package/src/container.ts +411 -113
  18. package/src/helper.ts +189 -5
  19. package/src/introspection/generated.agi.ts +17664 -11568
  20. package/src/introspection/generated.node.ts +4891 -1860
  21. package/src/introspection/generated.web.ts +379 -291
  22. package/src/introspection/index.ts +7 -0
  23. package/src/introspection/scan.ts +224 -7
  24. package/src/node/container.ts +31 -10
  25. package/src/node/features/content-db.ts +7 -7
  26. package/src/node/features/disk-cache.ts +11 -11
  27. package/src/node/features/esbuild.ts +3 -3
  28. package/src/node/features/file-manager.ts +37 -16
  29. package/src/node/features/fs.ts +64 -25
  30. package/src/node/features/git.ts +10 -10
  31. package/src/node/features/helpers.ts +25 -18
  32. package/src/node/features/ink.ts +13 -13
  33. package/src/node/features/ipc-socket.ts +8 -8
  34. package/src/node/features/networking.ts +3 -3
  35. package/src/node/features/os.ts +7 -7
  36. package/src/node/features/package-finder.ts +15 -15
  37. package/src/node/features/proc.ts +1 -1
  38. package/src/node/features/ui.ts +13 -13
  39. package/src/node/features/vm.ts +4 -4
  40. package/src/scaffolds/generated.ts +1 -1
  41. package/src/servers/express.ts +6 -6
  42. package/src/servers/mcp.ts +4 -4
  43. package/src/servers/socket.ts +6 -6
  44. package/test/interceptor-chain.test.ts +61 -0
  45. package/docs/apis/features/node/window-manager.md +0 -445
  46. package/docs/examples/window-manager-layouts.md +0 -180
  47. package/docs/examples/window-manager.md +0 -125
  48. package/docs/window-manager-fix.md +0 -249
  49. package/scripts/test-window-manager-lifecycle.ts +0 -86
  50. package/scripts/test-window-manager.ts +0 -43
  51. package/src/node/features/window-manager.ts +0 -1603
@@ -125,6 +125,44 @@ export type ConversationState = z.infer<typeof ConversationStateSchema>
125
125
 
126
126
  export type AskOptions = {
127
127
  maxTokens?: number
128
+ /**
129
+ * When provided, enables OpenAI Structured Outputs. The model is constrained
130
+ * to return JSON matching this Zod schema. The return value of ask() will be
131
+ * the parsed object instead of a raw string.
132
+ */
133
+ schema?: z.ZodType
134
+ }
135
+
136
+ /**
137
+ * Recursively set `additionalProperties: false` on every object-type node
138
+ * in a JSON Schema tree. OpenAI strict mode requires this at every level.
139
+ * Also ensures every object has a `required` array listing all its property keys.
140
+ */
141
+ function strictifySchema(schema: Record<string, any>): Record<string, any> {
142
+ const clone = { ...schema }
143
+
144
+ if (clone.type === 'object' && clone.properties) {
145
+ clone.additionalProperties = false
146
+ clone.required = Object.keys(clone.properties)
147
+ const props: Record<string, any> = {}
148
+ for (const [key, val] of Object.entries(clone.properties)) {
149
+ props[key] = strictifySchema(val as Record<string, any>)
150
+ }
151
+ clone.properties = props
152
+ }
153
+
154
+ if (clone.items) {
155
+ clone.items = strictifySchema(clone.items)
156
+ }
157
+
158
+ // anyOf / oneOf / allOf
159
+ for (const combiner of ['anyOf', 'oneOf', 'allOf'] as const) {
160
+ if (Array.isArray(clone[combiner])) {
161
+ clone[combiner] = clone[combiner].map((s: Record<string, any>) => strictifySchema(s))
162
+ }
163
+ }
164
+
165
+ return clone
128
166
  }
129
167
 
130
168
  /**
@@ -151,6 +189,16 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
151
189
 
152
190
  static { Feature.register(this, 'conversation') }
153
191
 
192
+ /**
193
+ * Pluggable tool executor. Called for each tool invocation with the tool
194
+ * name, parsed args, and the default handler. Return the serialized result string.
195
+ * The Assistant replaces this to wire in beforeToolCall/afterToolCall interceptors.
196
+ */
197
+ toolExecutor: ((name: string, args: Record<string, any>, handler: (...args: any[]) => Promise<any>) => Promise<string>) | null = null
198
+
199
+ /** The active structured output schema for the current ask() call, if any. */
200
+ private _activeSchema: z.ZodType | null = null
201
+
154
202
  /** Resolved max tokens: per-call override > options-level > undefined (no limit). */
155
203
  private get maxTokens(): number | undefined {
156
204
  return (this.state.get('callMaxTokens') as number | null) ?? this.options.maxTokens ?? undefined
@@ -419,6 +467,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
419
467
  */
420
468
  async ask(content: string | ContentPart[], options?: AskOptions): Promise<string> {
421
469
  this.state.set('callMaxTokens', options?.maxTokens ?? null)
470
+ this._activeSchema = options?.schema ?? null
422
471
 
423
472
  // Auto-compact before adding the new message
424
473
  if (this.options.autoCompact) {
@@ -436,6 +485,8 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
436
485
  this.emit('userMessage', content)
437
486
 
438
487
  try {
488
+ let raw: string
489
+
439
490
  if (this.apiMode === 'responses') {
440
491
  const previousResponseId = this.state.get('lastResponseId') || undefined
441
492
  let input: OpenAI.Responses.ResponseInput
@@ -449,17 +500,31 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
449
500
  input = this.messagesToResponsesInput()
450
501
  }
451
502
 
452
- return await this.runResponsesLoop({
503
+ raw = await this.runResponsesLoop({
453
504
  turn: 1,
454
505
  accumulated: '',
455
506
  input,
456
507
  previousResponseId,
457
508
  })
509
+ } else {
510
+ raw = await this.runChatCompletionLoop({ turn: 1, accumulated: '' })
458
511
  }
459
512
 
460
- return await this.runChatCompletionLoop({ turn: 1, accumulated: '' })
513
+ // When a structured output schema is active, parse the JSON response
514
+ if (this._activeSchema) {
515
+ try {
516
+ const parsed = JSON.parse(raw)
517
+ return parsed
518
+ } catch {
519
+ // Model returned something that isn't valid JSON — return raw
520
+ return raw
521
+ }
522
+ }
523
+
524
+ return raw
461
525
  } finally {
462
526
  this.state.set('callMaxTokens', null)
527
+ this._activeSchema = null
463
528
  }
464
529
  }
465
530
 
@@ -545,6 +610,28 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
545
610
  return input
546
611
  }
547
612
 
613
+ /**
614
+ * Build the OpenAI response_format / text.format config from the active Zod schema.
615
+ * Returns undefined when no schema is active.
616
+ */
617
+ private get structuredOutputConfig(): { name: string; schema: Record<string, any>; strict: true } | undefined {
618
+ if (!this._activeSchema) return undefined
619
+
620
+ const raw = (this._activeSchema as any).toJSONSchema() as Record<string, any>
621
+ const strict = strictifySchema(raw)
622
+
623
+ // Derive a name from the schema description or fall back to a default.
624
+ // OpenAI requires [a-zA-Z0-9_-] max 64 chars.
625
+ const desc = raw.description || 'structured_output'
626
+ const name = desc.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64)
627
+
628
+ return {
629
+ name,
630
+ schema: { type: strict.type || 'object', properties: strict.properties, required: strict.required, additionalProperties: false },
631
+ strict: true,
632
+ }
633
+ }
634
+
548
635
  /** Returns the OpenAI client instance from the container. */
549
636
  get openai() {
550
637
  let baseURL = this.options.clientOptions?.baseURL ? this.options.clientOptions.baseURL : undefined
@@ -603,6 +690,40 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
603
690
  })
604
691
  }
605
692
 
693
+ /**
694
+ * Execute a single tool call, routing through the pluggable toolExecutor
695
+ * if one is set (e.g. by the Assistant's interceptor chain).
696
+ */
697
+ private async executeTool(toolName: string, rawArgs: string): Promise<string> {
698
+ const tool = this.tools[toolName]
699
+ const callCount = (this.state.get('toolCalls') || 0) + 1
700
+ this.state.set('toolCalls', callCount)
701
+
702
+ if (!tool) {
703
+ const result = JSON.stringify({ error: `Unknown tool: ${toolName}` })
704
+ this.emit('toolError', toolName, result)
705
+ return result
706
+ }
707
+
708
+ if (this.toolExecutor) {
709
+ const args = rawArgs ? JSON.parse(rawArgs) : {}
710
+ return this.toolExecutor(toolName, args, tool.handler)
711
+ }
712
+
713
+ try {
714
+ const args = rawArgs ? JSON.parse(rawArgs) : {}
715
+ this.emit('toolCall', toolName, args)
716
+ const output = await tool.handler(args)
717
+ const result = typeof output === 'string' ? output : JSON.stringify(output)
718
+ this.emit('toolResult', toolName, result)
719
+ return result
720
+ } catch (err: any) {
721
+ const result = JSON.stringify({ error: err.message || String(err) })
722
+ this.emit('toolError', toolName, err)
723
+ return result
724
+ }
725
+ }
726
+
606
727
  /**
607
728
  * Runs the streaming Responses API loop. Handles local function calls by
608
729
  * executing handlers and submitting `function_call_output` items until
@@ -625,6 +746,10 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
625
746
  this.state.set('streaming', true)
626
747
  this.emit('turnStart', { turn, isFollowUp: turn > 1 })
627
748
 
749
+ const textFormat = this.structuredOutputConfig
750
+ ? { text: { format: { type: 'json_schema' as const, ...this.structuredOutputConfig } } }
751
+ : {}
752
+
628
753
  try {
629
754
  const stream = await this.openai.raw.responses.create({
630
755
  model: this.model as OpenAI.Responses.ResponseCreateParams['model'],
@@ -634,6 +759,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
634
759
  ...(toolsParam ? { tools: toolsParam, tool_choice: 'auto', parallel_tool_calls: true } : {}),
635
760
  ...(this.responsesInstructions ? { instructions: this.responsesInstructions } : {}),
636
761
  ...(this.maxTokens ? { max_output_tokens: this.maxTokens } : {}),
762
+ ...textFormat,
637
763
  })
638
764
 
639
765
  for await (const event of stream) {
@@ -690,27 +816,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
690
816
 
691
817
  const functionOutputs: OpenAI.Responses.ResponseInputItem.FunctionCallOutput[] = []
692
818
  for (const call of functionCalls) {
693
- const toolName = call.name
694
- const tool = this.tools[toolName]
695
- const callCount = (this.state.get('toolCalls') || 0) + 1
696
- this.state.set('toolCalls', callCount)
697
-
698
- let result: string
699
- if (!tool) {
700
- result = JSON.stringify({ error: `Unknown tool: ${toolName}` })
701
- this.emit('toolError', toolName, result)
702
- } else {
703
- try {
704
- const args = call.arguments ? JSON.parse(call.arguments) : {}
705
- this.emit('toolCall', toolName, args)
706
- const output = await tool.handler(args)
707
- result = typeof output === 'string' ? output : JSON.stringify(output)
708
- this.emit('toolResult', toolName, result)
709
- } catch (err: any) {
710
- result = JSON.stringify({ error: err.message || String(err) })
711
- this.emit('toolError', toolName, err)
712
- }
713
- }
819
+ const result = await this.executeTool(call.name, call.arguments || '{}')
714
820
 
715
821
  this.pushMessage({
716
822
  role: 'tool',
@@ -785,6 +891,10 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
785
891
  let turnContent = ''
786
892
  let toolCalls: Array<{ id: string; function: { name: string; arguments: string }; type: 'function' }> = []
787
893
 
894
+ const responseFormat = this.structuredOutputConfig
895
+ ? { response_format: { type: 'json_schema' as const, json_schema: this.structuredOutputConfig } }
896
+ : {}
897
+
788
898
  try {
789
899
  const stream = await this.openai.raw.chat.completions.create({
790
900
  model: this.model,
@@ -792,6 +902,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
792
902
  stream: true,
793
903
  ...(toolsParam ? { tools: toolsParam, tool_choice: 'auto' } : {}),
794
904
  ...(this.maxTokens ? { max_tokens: this.maxTokens } : {}),
905
+ ...responseFormat,
795
906
  })
796
907
 
797
908
  for await (const chunk of stream) {
@@ -850,28 +961,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
850
961
  this.emit('toolCallsStart', toolCalls)
851
962
 
852
963
  for (const tc of toolCalls) {
853
- const toolName = tc.function.name
854
- const tool = this.tools[toolName]
855
- const callCount = (this.state.get('toolCalls') || 0) + 1
856
- this.state.set('toolCalls', callCount)
857
-
858
- let result: string
859
-
860
- if (!tool) {
861
- result = JSON.stringify({ error: `Unknown tool: ${toolName}` })
862
- this.emit('toolError', toolName, result)
863
- } else {
864
- try {
865
- const args = JSON.parse(tc.function.arguments)
866
- this.emit('toolCall', toolName, args)
867
- const output = await tool.handler(args)
868
- result = typeof output === 'string' ? output : JSON.stringify(output)
869
- this.emit('toolResult', toolName, result)
870
- } catch (err: any) {
871
- result = JSON.stringify({ error: err.message || String(err) })
872
- this.emit('toolError', toolName, err)
873
- }
874
- }
964
+ const result = await this.executeTool(tc.function.name, tc.function.arguments)
875
965
 
876
966
  const toolMessage: OpenAI.Chat.Completions.ChatCompletionToolMessageParam = {
877
967
  role: 'tool',
@@ -0,0 +1,79 @@
1
+ /**
2
+ * A composable middleware chain. Each interceptor receives a mutable
3
+ * context and a `next` function. Calling `next()` continues the chain;
4
+ * skipping it short-circuits.
5
+ */
6
+
7
+ export type InterceptorFn<T> = (ctx: T, next: () => Promise<void>) => Promise<void>
8
+
9
+ export class InterceptorChain<T> {
10
+ private fns: InterceptorFn<T>[] = []
11
+
12
+ add(fn: InterceptorFn<T>): void {
13
+ this.fns.push(fn)
14
+ }
15
+
16
+ remove(fn: InterceptorFn<T>): void {
17
+ const idx = this.fns.indexOf(fn)
18
+ if (idx !== -1) this.fns.splice(idx, 1)
19
+ }
20
+
21
+ get hasInterceptors(): boolean {
22
+ return this.fns.length > 0
23
+ }
24
+
25
+ get size(): number {
26
+ return this.fns.length
27
+ }
28
+
29
+ async run(ctx: T, final: () => Promise<void>): Promise<void> {
30
+ let index = 0
31
+ const fns = this.fns
32
+
33
+ const next = async (): Promise<void> => {
34
+ if (index < fns.length) {
35
+ const fn = fns[index++]!
36
+ await fn(ctx, next)
37
+ } else {
38
+ await final()
39
+ }
40
+ }
41
+
42
+ await next()
43
+ }
44
+ }
45
+
46
+ export interface BeforeAskCtx {
47
+ question: string | any[]
48
+ options?: any
49
+ result?: string
50
+ }
51
+
52
+ export interface ToolCallCtx {
53
+ name: string
54
+ args: Record<string, any>
55
+ result?: string
56
+ error?: any
57
+ skip?: boolean
58
+ }
59
+
60
+ export interface BeforeResponseCtx {
61
+ text: string
62
+ }
63
+
64
+ export interface BeforeTurnCtx {
65
+ turn: number
66
+ isFollowUp: boolean
67
+ messages: any[]
68
+ skip?: boolean
69
+ }
70
+
71
+ export interface InterceptorPoints {
72
+ beforeAsk: BeforeAskCtx
73
+ beforeTurn: BeforeTurnCtx
74
+ beforeToolCall: ToolCallCtx
75
+ afterToolCall: ToolCallCtx
76
+ beforeResponse: BeforeResponseCtx
77
+ }
78
+
79
+ export type InterceptorPoint = keyof InterceptorPoints