@opencode-ai/ai
Advanced tools
@@ -568,2 +568,3 @@ import { Effect, Schema } from "effect"; | ||
| readonly tools: ToolStream.State<string>; | ||
| readonly completedTools: ReadonlySet<string>; | ||
| readonly hasFunctionCall: boolean; | ||
@@ -570,0 +571,0 @@ readonly lifecycle: Lifecycle.State; |
@@ -628,15 +628,25 @@ import { Effect, Schema } from "effect"; | ||
| export const outputItemID = (state, event) => event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id); | ||
| export const onReasoningDelta = (state, event, itemID) => { | ||
| const startReasoningSummaryPart = (state, itemID, index) => { | ||
| const item = state.reasoningItems[itemID]; | ||
| if (!event.delta || !item) | ||
| if (!item || index === 0 || item.summaryParts[index] !== undefined) | ||
| return [state, NO_EVENTS]; | ||
| const index = event.summary_index ?? 0; | ||
| if (Object.values(item.summaryParts).every((status) => status === "concluded")) | ||
| return [state, NO_EVENTS]; | ||
| const events = []; | ||
| const lifecycle = Object.entries(item.summaryParts) | ||
| .filter((entry) => entry[1] !== "concluded") | ||
| .reduce((lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${itemID}:${entry[0]}`, providerMetadata(state, { itemId: itemID })), state.lifecycle); | ||
| return [ | ||
| { | ||
| ...state, | ||
| lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta), | ||
| lifecycle: Lifecycle.reasoningStart(lifecycle, events, `${itemID}:${index}`, providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null })), | ||
| reasoningItems: { | ||
| ...state.reasoningItems, | ||
| [itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) }, | ||
| [itemID]: { | ||
| ...item, | ||
| summaryParts: { | ||
| ...Object.fromEntries(Object.entries(item.summaryParts).map((entry) => entry[1] === "concluded" ? entry : [entry[0], "concluded"])), | ||
| [index]: "active", | ||
| }, | ||
| }, | ||
| }, | ||
@@ -647,2 +657,30 @@ }, | ||
| }; | ||
| export const onReasoningDelta = (state, event, itemID) => { | ||
| const item = state.reasoningItems[itemID]; | ||
| if (!event.delta || !item) | ||
| return [state, NO_EVENTS]; | ||
| const index = event.summary_index ?? 0; | ||
| if (item.summaryParts[index] === "concluded") | ||
| return [state, NO_EVENTS]; | ||
| // An unseen index cannot reopen an item whose parts have all concluded. | ||
| if (item.summaryParts[index] === undefined && | ||
| Object.values(item.summaryParts).every((status) => status === "concluded")) | ||
| return [state, NO_EVENTS]; | ||
| const started = item.summaryParts[index] === undefined ? startReasoningSummaryPart(state, itemID, index) : [state, NO_EVENTS]; | ||
| const current = started[0].reasoningItems[itemID]; | ||
| if (!current) | ||
| return started; | ||
| const events = [...started[1]]; | ||
| return [ | ||
| { | ||
| ...started[0], | ||
| lifecycle: Lifecycle.reasoningDelta(started[0].lifecycle, events, `${itemID}:${index}`, event.delta), | ||
| reasoningItems: { | ||
| ...started[0].reasoningItems, | ||
| [itemID]: { ...current, deltaIndexes: new Set([...current.deltaIndexes, index]) }, | ||
| }, | ||
| }, | ||
| events, | ||
| ]; | ||
| }; | ||
| // Some compatible gateways emit a reasoning final without streaming any | ||
@@ -661,3 +699,3 @@ // deltas, mirroring `response.output_text.done`. Reconcile the complete text | ||
| const reasoningMetadata = (state, item) => providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null }); | ||
| // Responses APIs stream reasoning items in a stable order: | ||
| // Responses APIs normally stream reasoning items in this order: | ||
| // `output_item.added` (reasoning) → | ||
@@ -669,21 +707,34 @@ // `reasoning_summary_part.added` (index=0) → | ||
| // `output_item.done` (reasoning). | ||
| // The handlers below rely on this ordering: `onOutputItemAdded` seeds the | ||
| // per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0` | ||
| // short-circuits when the entry already exists, and higher-index handlers | ||
| // fold against the same entry. Behaviour for out-of-order events is | ||
| // best-effort, not guaranteed. | ||
| // `onOutputItemAdded` seeds the per-item entry, while each later part start is | ||
| // also an implicit boundary for the previous part. This keeps the common event | ||
| // lifecycle ordered when a compatible provider omits or delays a part-done event. | ||
| const onOutputItemAdded = (state, event) => { | ||
| const item = event.item; | ||
| if (item?.type === "message" && item.id !== undefined) { | ||
| const itemID = item.id; | ||
| const phase = messagePhase(item.phase); | ||
| // A new message item is an implicit boundary for every earlier message | ||
| // item: text still streaming is ended, and all older items leave the | ||
| // tracked set so their late deltas stay no-ops instead of overlapping. | ||
| const events = []; | ||
| const lifecycle = [...state.lifecycle.text] | ||
| .filter((id) => id !== itemID) | ||
| .reduce((lifecycle, id) => { | ||
| const openPhase = state.messagePhases[id]; | ||
| return Lifecycle.textEnd(lifecycle, events, id, providerMetadata(state, { itemId: id, ...(openPhase === undefined ? {} : { phase: openPhase }) })); | ||
| }, state.lifecycle); | ||
| const nextPhase = phase === undefined ? state.messagePhases[itemID] : phase; | ||
| return [ | ||
| { | ||
| ...state, | ||
| messageItems: new Set([...state.messageItems, item.id]), | ||
| messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }, | ||
| lifecycle, | ||
| messageItems: new Set([itemID]), | ||
| messagePhases: nextPhase === undefined ? {} : { [itemID]: nextPhase }, | ||
| }, | ||
| NO_EVENTS, | ||
| events, | ||
| ]; | ||
| } | ||
| if (item && isReasoningItem(item)) { | ||
| if (state.reasoningItems[item.id] !== undefined) | ||
| return [state, NO_EVENTS]; | ||
| const events = []; | ||
@@ -709,2 +760,6 @@ return [ | ||
| const id = item.id ?? item.call_id; | ||
| // Pending tools always store the call id, so this also catches duplicates | ||
| // that disagree on whether `item.id` is present. | ||
| if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id)) | ||
| return [state, NO_EVENTS]; | ||
| const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined; | ||
@@ -730,28 +785,3 @@ const events = []; | ||
| return [state, NO_EVENTS]; | ||
| const item = state.reasoningItems[event.item_id]; | ||
| if (!item) | ||
| return [state, NO_EVENTS]; | ||
| if (event.summary_index === 0) | ||
| return [state, NO_EVENTS]; | ||
| const events = []; | ||
| const closed = Object.entries(item.summaryParts) | ||
| .filter((entry) => entry[1] === "can-conclude") | ||
| .reduce((lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${event.item_id}:${entry[0]}`, providerMetadata(state, { itemId: event.item_id })), state.lifecycle); | ||
| return [ | ||
| { | ||
| ...state, | ||
| lifecycle: Lifecycle.reasoningStart(closed, events, `${event.item_id}:${event.summary_index}`, providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null })), | ||
| reasoningItems: { | ||
| ...state.reasoningItems, | ||
| [event.item_id]: { | ||
| ...item, | ||
| summaryParts: { | ||
| ...Object.fromEntries(Object.entries(item.summaryParts).map((entry) => entry[1] === "can-conclude" ? [entry[0], "concluded"] : entry)), | ||
| [event.summary_index]: "active", | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| events, | ||
| ]; | ||
| return startReasoningSummaryPart(state, event.item_id, event.summary_index); | ||
| }; | ||
@@ -764,2 +794,4 @@ const onReasoningSummaryPartDone = (state, event) => { | ||
| return [state, NO_EVENTS]; | ||
| if (item.summaryParts[event.summary_index] !== "active") | ||
| return [state, NO_EVENTS]; | ||
| return [ | ||
@@ -831,9 +863,19 @@ { | ||
| return [state, NO_EVENTS]; | ||
| const id = item.id ?? item.call_id; | ||
| const tools = state.tools[id] | ||
| const callID = item.call_id; | ||
| if (state.completedTools.has(callID)) | ||
| return [state, NO_EVENTS]; | ||
| const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined; | ||
| const fallback = item.id ?? callID; | ||
| // Match the pending tool by call id so item events that disagree on | ||
| // whether `item.id` is present still resolve the same call. | ||
| const registered = state.tools[fallback] !== undefined | ||
| ? fallback | ||
| : Object.keys(state.tools).find((key) => state.tools[key]?.id === callID); | ||
| const id = registered ?? fallback; | ||
| const tools = registered !== undefined | ||
| ? state.tools | ||
| : ToolStream.start(state.tools, id, { | ||
| id: item.call_id, | ||
| id: callID, | ||
| name: item.name, | ||
| providerMetadata: item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined, | ||
| providerMetadata: metadata, | ||
| }); | ||
@@ -844,3 +886,7 @@ const result = item.arguments === undefined | ||
| const events = []; | ||
| const resultEvents = result.events ?? []; | ||
| const finished = result.events ?? []; | ||
| // A done-only call never streamed a start event, so open its lifecycle here. | ||
| const resultEvents = registered !== undefined || finished.length === 0 | ||
| ? finished | ||
| : [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]; | ||
| const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle; | ||
@@ -855,2 +901,3 @@ events.push(...resultEvents); | ||
| tools: result.tools, | ||
| completedTools: new Set([...state.completedTools, callID]), | ||
| }, | ||
@@ -868,4 +915,19 @@ events, | ||
| .reduce((lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata), state.lifecycle); | ||
| const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems; | ||
| return [{ ...state, lifecycle, reasoningItems }, events]; | ||
| // Keep the fully-concluded entry so duplicate or late events for this | ||
| // item remain no-ops instead of reopening lifecycle state. | ||
| return [ | ||
| { | ||
| ...state, | ||
| lifecycle, | ||
| reasoningItems: { | ||
| ...state.reasoningItems, | ||
| [item.id]: { | ||
| ...reasoningItem, | ||
| encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent, | ||
| summaryParts: Object.fromEntries(Object.keys(reasoningItem.summaryParts).map((index) => [index, "concluded"])), | ||
| }, | ||
| }, | ||
| }, | ||
| events, | ||
| ]; | ||
| } | ||
@@ -876,3 +938,17 @@ if (!state.lifecycle.reasoning.has(item.id)) { | ||
| events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata })); | ||
| return [{ ...state, lifecycle }, events]; | ||
| return [ | ||
| { | ||
| ...state, | ||
| lifecycle, | ||
| reasoningItems: { | ||
| ...state.reasoningItems, | ||
| [item.id]: { | ||
| encryptedContent: item.encrypted_content, | ||
| summaryParts: { 0: "concluded" }, | ||
| deltaIndexes: new Set(), | ||
| }, | ||
| }, | ||
| }, | ||
| events, | ||
| ]; | ||
| } | ||
@@ -994,2 +1070,7 @@ return [ | ||
| return ProviderShared.eventError(state.id, `${event.type} message is missing id`); | ||
| if (event.item && | ||
| isReasoningItem(event.item) && | ||
| state.reasoningItems[event.item.id] === undefined && | ||
| state.lifecycle.reasoning.size > 0) | ||
| return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`); | ||
| const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined); | ||
@@ -1030,2 +1111,3 @@ return Effect.succeed(onOutputItemAdded(event.output_index !== undefined && id !== undefined | ||
| tools: ToolStream.empty(), | ||
| completedTools: new Set(), | ||
| lifecycle: Lifecycle.initial(), | ||
@@ -1032,0 +1114,0 @@ outputItems: {}, |
+3
-3
| { | ||
| "$schema": "https://json.schemastore.org/package.json", | ||
| "version": "0.0.0-dev-18539", | ||
| "version": "0.0.0-dev-18544", | ||
| "name": "@opencode-ai/ai", | ||
@@ -33,3 +33,3 @@ "type": "module", | ||
| "@effect/platform-node": "4.0.0-rc.112", | ||
| "@opencode-ai/http-recorder": "0.0.0-dev-18539", | ||
| "@opencode-ai/http-recorder": "0.0.0-dev-18544", | ||
| "@tsconfig/bun": "1.0.9", | ||
@@ -43,3 +43,3 @@ "@types/bun": "1.3.13", | ||
| "@smithy/util-utf8": "4.2.2", | ||
| "@opencode-ai/schema": "0.0.0-dev-18539", | ||
| "@opencode-ai/schema": "0.0.0-dev-18544", | ||
| "aws4fetch": "1.0.20", | ||
@@ -46,0 +46,0 @@ "effect": "4.0.0-rc.112", |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1468521
0.29%33899
0.25%+ Added
- Removed