mirror of
https://github.com/Heretek-AI/GDevelop-Unlocked.git
synced 2026-11-07 10:55:58 -10:00
Webpack compile caught what markers cannot: the rebased AskAi container imports session/studio helpers from three more fork-diverged files. - 12 AiRequestUtils.js (verbatim, no upstream drift): 10 session/studio helpers incl. canStartAiRequestCreate (the exact webpack failure). - 13 AiRequestContext.js (1 conflict, keep-both): BYOK session handling, local-cache merge, sub-agent finalize wiring. - 14 AiGeneration/Utils.js (2 conflicts, keep-both): useProcessFunctionCalls studio args + enqueueRequestWrite return; upstream attachment-save prop kept. - Permanent gate: patched-import-closure test resolves every relative import across the patched surface (negative-controlled vs patch 12). - Driver markers, apply-test file list, tier-2 GLUE extended; docs updated.
746 lines
29 KiB
Diff
746 lines
29 KiB
Diff
--- a/newIDE/app/src/AiGeneration/AiRequestUtils.js
|
||
+++ b/newIDE/app/src/AiGeneration/AiRequestUtils.js
|
||
@@ -12,11 +12,17 @@
|
||
import { type RelatedAiRequestLastMessages } from '../EditorFunctions';
|
||
|
||
/** The text typed by the user in one of their messages. */
|
||
-export const getUserRequestText = (message: AiRequestUserMessage): string =>
|
||
- message.content
|
||
- .filter(content => content.type === 'user_request')
|
||
- .map(content => content.text)
|
||
+export const getUserRequestText = (message: AiRequestUserMessage): string => {
|
||
+ // `content` comes from the server unvalidated (a hosted summary carries the
|
||
+ // raw first message). A payload missing it, or holding a non-array, would
|
||
+ // throw here and take the chat list down with it, so tolerate both.
|
||
+ const content = message && message.content;
|
||
+ if (!Array.isArray(content)) return '';
|
||
+ return content
|
||
+ .filter(item => item && item.type === 'user_request' && item.text)
|
||
+ .map(item => item.text)
|
||
.join(' ');
|
||
+};
|
||
|
||
/**
|
||
* The name of a chat: the title the user gave to it, or its first message
|
||
@@ -49,6 +55,559 @@
|
||
(aiRequest.retriesInARowCount || 0) >= MAX_AI_REQUEST_RETRIES_IN_A_ROW
|
||
);
|
||
|
||
+/**
|
||
+ * Whether this session may offer the error row's Retry for one request id.
|
||
+ * Mirrors canSendAiRequestForSession: any profile, or a local-ai-* id that
|
||
+ * lives only in the local cache (no hosted API / auth needed). Non-local ids
|
||
+ * without a profile are refused — hosted /action/retry requires a userId.
|
||
+ */
|
||
+export const canRetryAiRequestForSession = (
|
||
+ profile: ?{ id: string },
|
||
+ aiRequestId: string
|
||
+): boolean => !!profile || aiRequestId.startsWith('local-ai-');
|
||
+
|
||
+/**
|
||
+ * Whether this session may send message feedback for one request id.
|
||
+ * Mirrors canRetryAiRequestForSession: any profile, or a local-ai-* id that
|
||
+ * lives only in the local cache (the Generation layer short-circuits those
|
||
+ * before any hosted call). Hosted non-local ids without a profile are refused.
|
||
+ */
|
||
+export const canSendFeedbackForSession = (
|
||
+ profile: ?{ id: string },
|
||
+ aiRequestId: string
|
||
+): boolean => !!profile || aiRequestId.startsWith('local-ai-');
|
||
+
|
||
+/**
|
||
+ * Whether a createAiRequest return value is a first-turn failure or cancel
|
||
+ * that must not be treated as a successful start (local BYOK returns
|
||
+ * status:'error' on failure and status:'suspended' when the user aborts a hung
|
||
+ * create, instead of throwing).
|
||
+ */
|
||
+export const isFailedAiRequestStart = (aiRequest: {
|
||
+ status: string,
|
||
+ ...
|
||
+}): boolean => aiRequest.status === 'error' || aiRequest.status === 'suspended';
|
||
+
|
||
+/**
|
||
+ * Standalone create outcome: failed or cancelled first-turn starts stay on the
|
||
+ * error row (project left open so an offline failure or Stop does not discard
|
||
+ * the user's work); only successful starts hand off to the Ask AI tab / close
|
||
+ * the project.
|
||
+ */
|
||
+export const getStandaloneCreateOutcome = (aiRequest: {
|
||
+ status: string,
|
||
+ ...
|
||
+}): 'error-row' | 'handoff' =>
|
||
+ isFailedAiRequestStart(aiRequest) ? 'error-row' : 'handoff';
|
||
+
|
||
+/**
|
||
+ * Whether a send/continue of an AI request may proceed for this session:
|
||
+ * any logged-in profile, or a local/BYOK session with a custom endpoint
|
||
+ * enabled (the hosted API is never involved for those). Hosted non-local
|
||
+ * requests without a profile are refused.
|
||
+ *
|
||
+ * Shared by the editor container and the stand-alone form so the two cannot
|
||
+ * drift — the stand-alone form used to require a profile for function-call
|
||
+ * outputs even when a custom endpoint was enabled, which silently broke the
|
||
+ * agent loop for offline BYOK sessions.
|
||
+ *
|
||
+ * A local-ai-* id is also allowed without a profile or custom endpoint: those
|
||
+ * chats live only in the local cache (cycle 64 lists them after logout /
|
||
+ * toggle-off, and Generation addMessage short-circuits them before any
|
||
+ * hosted call).
|
||
+ */
|
||
+export const canSendAiRequestForSession = (
|
||
+ profile: ?{ id: string },
|
||
+ customEndpointEnabled: boolean,
|
||
+ aiRequestId: ?string
|
||
+): boolean =>
|
||
+ !!profile ||
|
||
+ customEndpointEnabled ||
|
||
+ (!!aiRequestId && aiRequestId.startsWith('local-ai-'));
|
||
+
|
||
+/**
|
||
+ * Whether the AI editor tools (event generation, asset search, resource
|
||
+ * search) may run for this session.
|
||
+ *
|
||
+ * Always allowed: a profile uses the hosted APIs when the custom endpoint is
|
||
+ * off (or the local client when it is on). Without a profile the hooks set
|
||
+ * activeUserId to LOCAL_BYOK_USER_ID ('local-byok-user'), which Generation
|
||
+ * and prepareAiUserContent dual-gate onto the offline client before any
|
||
+ * authorized call — so a missing profile must not throw.
|
||
+ *
|
||
+ * The old `!profile && !customEndpointEnabled` throw blocked agent
|
||
+ * function-calls for local-ai-* chats after logout / endpoint toggle-off
|
||
+ * (cycles 64–66 list, send, and watch those chats offline).
|
||
+ */
|
||
+export const canUseEditorAiTools = (
|
||
+ profile: ?{ id: string },
|
||
+ customEndpointEnabled: boolean
|
||
+): boolean => {
|
||
+ if (profile) return true;
|
||
+ // Logged-out: LOCAL_BYOK_USER_ID starts with 'local-', so every Generation
|
||
+ // dual-gate accepts it regardless of the endpoint toggle. Accept the flag
|
||
+ // for call-site symmetry with canSendAiRequestForSession but do not require it.
|
||
+ return customEndpointEnabled || !profile;
|
||
+};
|
||
+
|
||
+/**
|
||
+ * Whether a new AI request may be created for this session (the stand-alone
|
||
+ * form and the editor container's create effect).
|
||
+ *
|
||
+ * Always allowed: a profile uses the hosted API when the custom endpoint is
|
||
+ * off (or the local client when it is on). Without a profile the forms set
|
||
+ * activeUserId to LOCAL_BYOK_USER_ID ('local-byok-user'), which Generation
|
||
+ * and prepareAiUserContent dual-gate onto the offline client — so create
|
||
+ * must not open the account dialog first.
|
||
+ *
|
||
+ * The old `!profile && !customEndpointEnabled` account-dialog gate blocked
|
||
+ * new local chats after logout / endpoint toggle-off even though cycles 64–66
|
||
+ * already list, send, and watch those sessions offline.
|
||
+ */
|
||
+export const canStartAiRequestCreate = (
|
||
+ profile: ?{ id: string },
|
||
+ customEndpointEnabled: boolean
|
||
+): boolean => {
|
||
+ if (profile) return true;
|
||
+ // Logged-out create uses LOCAL_BYOK_USER_ID → Generation local path.
|
||
+ return customEndpointEnabled || !profile;
|
||
+};
|
||
+
|
||
+/**
|
||
+ * Whether the editor container's mount-time "tab open" full fetch should run
|
||
+ * for the currently selected chat. Mirrors AiRequestContext.loadAiRequest:
|
||
+ * a profile always can; without one, local-ai-* ids (cache-only) and any id
|
||
+ * while a custom endpoint is enabled may fetch. Hosted non-local ids without
|
||
+ * a profile and without a custom endpoint are refused (the hosted API would
|
||
+ * 401).
|
||
+ */
|
||
+export const shouldFetchAiRequestOnTabOpen = (options: {|
|
||
+ selectedAiRequest: ?{ id: string },
|
||
+ profile: ?{ id: string },
|
||
+ customEndpointEnabled: boolean,
|
||
+|}): boolean => {
|
||
+ const { selectedAiRequest, profile, customEndpointEnabled } = options;
|
||
+ if (!selectedAiRequest) return false;
|
||
+ if (profile) return true;
|
||
+ if (selectedAiRequest.id.startsWith('local-ai-')) return true;
|
||
+ return customEndpointEnabled;
|
||
+};
|
||
+
|
||
+/**
|
||
+ * Whether the suggestions side-fetch should run for this session and request
|
||
+ * state: an agent/orchestrator request that is ready with a project loaded,
|
||
+ * not mid-send, and allowed for this session (profile or custom endpoint).
|
||
+ *
|
||
+ * Offline BYOK without a profile used to be refused by a bare `!profile`
|
||
+ * guard in Utils, so local chats never received next-step suggestions even
|
||
+ * though the local client can generate them without the hosted API.
|
||
+ */
|
||
+export const shouldFetchAiRequestSuggestions = (options: {|
|
||
+ selectedAiRequest: ?AiRequest,
|
||
+ isSending: boolean,
|
||
+ isFetchingSuggestions: boolean,
|
||
+ profile: ?{ id: string },
|
||
+ customEndpointEnabled: boolean,
|
||
+ hasProject: boolean,
|
||
+|}): boolean => {
|
||
+ const {
|
||
+ selectedAiRequest,
|
||
+ isSending,
|
||
+ isFetchingSuggestions,
|
||
+ profile,
|
||
+ customEndpointEnabled,
|
||
+ hasProject,
|
||
+ } = options;
|
||
+ if (!selectedAiRequest) return false;
|
||
+ if (
|
||
+ selectedAiRequest.mode !== 'agent' &&
|
||
+ selectedAiRequest.mode !== 'orchestrator'
|
||
+ ) {
|
||
+ return false;
|
||
+ }
|
||
+ if (isSending) return false;
|
||
+ if (!selectedAiRequest.output || selectedAiRequest.output.length === 0) {
|
||
+ return false;
|
||
+ }
|
||
+ if (selectedAiRequest.status !== 'ready') return false;
|
||
+ if (
|
||
+ !canSendAiRequestForSession(
|
||
+ profile,
|
||
+ customEndpointEnabled,
|
||
+ selectedAiRequest.id
|
||
+ )
|
||
+ ) {
|
||
+ return false;
|
||
+ }
|
||
+ if (isFetchingSuggestions) return false;
|
||
+ if (!hasProject) return false;
|
||
+ return true;
|
||
+};
|
||
+
|
||
+/**
|
||
+ * The share of the model's context window the opened chat has used, 0 to 1
|
||
+ * (it can exceed 1), or null when it is not known.
|
||
+ *
|
||
+ * The hosted path reports this on `aiRequest.contextStats`. The local BYOK
|
||
+ * path never sets that field — it accounts token counts itself — so without
|
||
+ * this the usage indicator stayed empty in exactly the mode where small local
|
||
+ * context windows make the number most useful.
|
||
+ *
|
||
+ * `totalTokens` is the chat's cumulative token count and `budgetTokens` the
|
||
+ * per-request input budget; both come from the caller so this stays pure.
|
||
+ */
|
||
+export const getLocalAiRequestContextUsedRatio = (
|
||
+ totalTokens: number,
|
||
+ budgetTokens: number
|
||
+): ?number => {
|
||
+ const usable = (value: number): boolean =>
|
||
+ typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||
+ if (!usable(totalTokens) || !usable(budgetTokens)) return null;
|
||
+ return totalTokens / budgetTokens;
|
||
+};
|
||
+/**
|
||
+ * Whether a persisted output entry is a user chat message. Output entries are
|
||
+ * not shape-validated on load, so every `.filter(message => ...)` over them
|
||
+ * must tolerate a null hole — and this predicate lives here (not inline in
|
||
+ * the dispatcher) so a spec can pin it: Utils.js pulls in three.js through
|
||
+ * the editor-function map and cannot be loaded by a test.
|
||
+ */
|
||
+/**
|
||
+ * How many sub-agents a request spawned and how many are finished (their call
|
||
+ * has a `function_call_output`). The chat shows this as a small audit line so
|
||
+ * the multi-agent activity is visible without opening each sub-agent. Derived
|
||
+ * purely from the request output, so it is also usable as the data layer for a
|
||
+ * per-agent dashboard.
|
||
+ */
|
||
+export const summarizeSubAgentActivity = (aiRequest: {
|
||
+ output?: Array<any>,
|
||
+}): { total: number, done: number, running: number, roles: Object } => {
|
||
+ // Single source of truth: the per-agent rows carry the role and the
|
||
+ // finished/live status; the summary is just their aggregate.
|
||
+ const rows = listSubAgentActivity((aiRequest: any), () => 0);
|
||
+ // Null-prototype so a model-authored role string like '__proto__' or
|
||
+ // 'constructor' becomes a normal key instead of touching Object.prototype.
|
||
+ const roles = Object.create(null);
|
||
+ let done = 0;
|
||
+ rows.forEach(row => {
|
||
+ if (row.role) roles[row.role] = (roles[row.role] || 0) + 1;
|
||
+ if (row.status === 'finished') done++;
|
||
+ });
|
||
+ const total = rows.length;
|
||
+ return { total, done, running: total - done, roles };
|
||
+};
|
||
+
|
||
+/**
|
||
+ * The summed token usage of a request's sub-agents, read through a getter
|
||
+ * (the token registry). Kept pure/testable: ChatMessages renders it, and the
|
||
+ * registry read is injected rather than imported, so this module stays
|
||
+ * dependency-free for its spec.
|
||
+ */
|
||
+export const sumSubAgentTokens = (
|
||
+ aiRequest: { output?: Array<any> },
|
||
+ getTokenTotal: string => number
|
||
+): number => {
|
||
+ const seen = new Set<string>();
|
||
+ return listSubAgentActivity((aiRequest: any), getTokenTotal).reduce(
|
||
+ (total, row) => {
|
||
+ // Token accounting is per sub-agent: a call repeated under one child id
|
||
+ // must not be counted twice.
|
||
+ if (!row.childId || seen.has(row.childId)) return total;
|
||
+ seen.add(row.childId);
|
||
+ return total + row.tokens;
|
||
+ },
|
||
+ 0
|
||
+ );
|
||
+};
|
||
+
|
||
+/** One row of the per-agent audit dashboard. */
|
||
+export type SubAgentActivityRow = {|
|
||
+ callId: string,
|
||
+ childId: string | null,
|
||
+ role: string | null,
|
||
+ shortTitle: string,
|
||
+ relatedTaskId: string | null,
|
||
+ status: 'working' | 'finished',
|
||
+ tokens: number,
|
||
+ /** The sub-agent's report, when it has finished and the output is readable. */
|
||
+ report: string | null,
|
||
+|};
|
||
+
|
||
+/**
|
||
+ * The human-readable report carried by a finished sub-agent's
|
||
+ * `function_call_output`.
|
||
+ *
|
||
+ * The output is network-/model-adjacent data and is only type-asserted, and the
|
||
+ * report is written by two different paths: `useStudioRuntime` posts
|
||
+ * `{ message: 'Report from the …' }`, while a hosted server may post a JSON
|
||
+ * string. Read both, and fall back to the raw string so a report is never
|
||
+ * silently dropped. `null` when there is nothing readable.
|
||
+ */
|
||
+export const extractFunctionCallReport = (output: any): string | null => {
|
||
+ if (typeof output === 'string') {
|
||
+ const trimmed = output.trim();
|
||
+ if (!trimmed) return null;
|
||
+ try {
|
||
+ const parsed = JSON.parse(trimmed);
|
||
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||
+ return extractFunctionCallReport(parsed);
|
||
+ }
|
||
+ // Parsed to a scalar/array: a machine payload, not a readable report.
|
||
+ return null;
|
||
+ } catch (ignored) {
|
||
+ // Not JSON: the string itself is the report.
|
||
+ return trimmed;
|
||
+ }
|
||
+ }
|
||
+ if (output && typeof output === 'object' && !Array.isArray(output)) {
|
||
+ if (typeof output.message === 'string' && output.message.trim())
|
||
+ return output.message;
|
||
+ if (typeof output.report === 'string' && output.report.trim())
|
||
+ return output.report;
|
||
+ }
|
||
+ return null;
|
||
+};
|
||
+
|
||
+/**
|
||
+ * Per-agent rows for the audit dashboard: one row per spawned sub-agent, in
|
||
+ * launch order, with its role, task link, live/finished status, its own token
|
||
+ * meter and its report once finished.
|
||
+ *
|
||
+ * `summarizeSubAgentActivity` answers "how many"; this answers "which ones" and
|
||
+ * is the data layer the dashboard (and any future multi-agent panel) reads.
|
||
+ * Derived purely from the parent's output plus an injected token getter, so it
|
||
+ * is testable without the React context or the token registry.
|
||
+ *
|
||
+ * A spawn call repeated under the same `call_id` (fork/merge) is counted once,
|
||
+ * matching `summarizeSubAgentActivity`.
|
||
+ */
|
||
+export const listSubAgentActivity = (
|
||
+ aiRequest: { output?: Array<any> },
|
||
+ getTokenTotal: string => number
|
||
+): Array<SubAgentActivityRow> => {
|
||
+ const output = (aiRequest && aiRequest.output) || [];
|
||
+ const outputByCallId = new Map<string, any>();
|
||
+ const firstCallByCallId = new Map<string, any>();
|
||
+
|
||
+ for (const message of output) {
|
||
+ if (!message || typeof message !== 'object') continue;
|
||
+ if (
|
||
+ message.type === 'function_call_output' &&
|
||
+ typeof message.call_id === 'string'
|
||
+ ) {
|
||
+ // First output wins, matching the call de-duplication below.
|
||
+ if (!outputByCallId.has(message.call_id)) {
|
||
+ outputByCallId.set(message.call_id, message);
|
||
+ }
|
||
+ continue;
|
||
+ }
|
||
+ if (
|
||
+ message.type === 'message' &&
|
||
+ message.role === 'assistant' &&
|
||
+ Array.isArray(message.content)
|
||
+ ) {
|
||
+ for (const entry of message.content) {
|
||
+ if (
|
||
+ !entry ||
|
||
+ typeof entry !== 'object' ||
|
||
+ entry.type !== 'function_call' ||
|
||
+ typeof entry.subAgentAiRequestId !== 'string' ||
|
||
+ typeof entry.call_id !== 'string'
|
||
+ ) {
|
||
+ continue;
|
||
+ }
|
||
+ if (!firstCallByCallId.has(entry.call_id)) {
|
||
+ firstCallByCallId.set(entry.call_id, entry);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ const rows: Array<SubAgentActivityRow> = [];
|
||
+ firstCallByCallId.forEach((call, callId) => {
|
||
+ let role = null;
|
||
+ let shortTitle = '';
|
||
+ let relatedTaskId = null;
|
||
+ try {
|
||
+ const parsed =
|
||
+ typeof call.arguments === 'string'
|
||
+ ? JSON.parse(call.arguments || 'null')
|
||
+ : call.arguments;
|
||
+ if (parsed && typeof parsed === 'object') {
|
||
+ if (typeof parsed.role === 'string' && parsed.role) role = parsed.role;
|
||
+ if (typeof parsed.short_title === 'string')
|
||
+ shortTitle = parsed.short_title;
|
||
+ if (
|
||
+ typeof parsed.related_task_id === 'string' &&
|
||
+ parsed.related_task_id
|
||
+ )
|
||
+ relatedTaskId = parsed.related_task_id;
|
||
+ }
|
||
+ } catch (ignored) {
|
||
+ // Malformed arguments: the row still shows the child and its status.
|
||
+ }
|
||
+ const childId =
|
||
+ typeof call.subAgentAiRequestId === 'string'
|
||
+ ? call.subAgentAiRequestId
|
||
+ : null;
|
||
+ let tokens = 0;
|
||
+ if (childId) {
|
||
+ const candidate = getTokenTotal(childId);
|
||
+ if (typeof candidate === 'number' && Number.isFinite(candidate))
|
||
+ tokens = candidate;
|
||
+ }
|
||
+ const outputMessage = outputByCallId.get(callId);
|
||
+ rows.push({
|
||
+ callId,
|
||
+ childId,
|
||
+ role,
|
||
+ shortTitle,
|
||
+ relatedTaskId,
|
||
+ status: outputMessage ? 'finished' : 'working',
|
||
+ tokens,
|
||
+ report: outputMessage
|
||
+ ? extractFunctionCallReport(outputMessage.output)
|
||
+ : null,
|
||
+ });
|
||
+ });
|
||
+
|
||
+ return rows;
|
||
+};
|
||
+
|
||
+/** One step of a sub-agent's own transcript. */
|
||
+export type SubAgentTranscriptStep = {|
|
||
+ kind: 'user' | 'text' | 'tool',
|
||
+ text: string,
|
||
+|};
|
||
+
|
||
+/** Cap one step's text so a verbose agent cannot flood the chat. */
|
||
+const MAX_TRANSCRIPT_STEP_LENGTH = 600;
|
||
+
|
||
+/**
|
||
+ * A compact, ordered view of a sub-agent's own conversation: its user request,
|
||
+ * its assistant text and the tools it called. This is the "multi-agent chat"
|
||
+ * half of the per-agent dashboard - what the agent actually did - without
|
||
+ * rendering the full, tool-heavy transcript.
|
||
+ *
|
||
+ * `maxSteps` bounds the list (the last step then marks the truncation). Derived
|
||
+ * purely from a request's output, so it is testable and safe on persisted,
|
||
+ * unvalidated data (null holes, non-array content).
|
||
+ */
|
||
+export const summarizeSubAgentTranscript = (
|
||
+ aiRequest: ?{ output?: Array<any> },
|
||
+ maxSteps?: number
|
||
+): Array<SubAgentTranscriptStep> => {
|
||
+ const output = (aiRequest && aiRequest.output) || [];
|
||
+ if (!Array.isArray(output)) return [];
|
||
+ const limit = typeof maxSteps === 'number' && maxSteps > 0 ? maxSteps : 30;
|
||
+ const steps: Array<SubAgentTranscriptStep> = [];
|
||
+ const truncate = (text: string): string =>
|
||
+ text.length > MAX_TRANSCRIPT_STEP_LENGTH
|
||
+ ? `${text.slice(0, MAX_TRANSCRIPT_STEP_LENGTH)}…`
|
||
+ : text;
|
||
+
|
||
+ for (const message of output) {
|
||
+ if (!message || typeof message !== 'object') continue;
|
||
+ if (message.type !== 'message' || !Array.isArray(message.content)) continue;
|
||
+ for (const content of message.content) {
|
||
+ if (!content || typeof content !== 'object') continue;
|
||
+ if (
|
||
+ message.role === 'user' &&
|
||
+ content.type === 'user_request' &&
|
||
+ typeof content.text === 'string' &&
|
||
+ content.text.trim()
|
||
+ ) {
|
||
+ steps.push({ kind: 'user', text: truncate(content.text) });
|
||
+ } else if (
|
||
+ message.role === 'assistant' &&
|
||
+ typeof content.text === 'string' &&
|
||
+ content.text.trim()
|
||
+ ) {
|
||
+ steps.push({ kind: 'text', text: truncate(content.text) });
|
||
+ } else if (
|
||
+ message.role === 'assistant' &&
|
||
+ content.type === 'function_call' &&
|
||
+ typeof content.name === 'string' &&
|
||
+ content.name
|
||
+ ) {
|
||
+ steps.push({ kind: 'tool', text: content.name });
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
+ if (steps.length <= limit) return steps;
|
||
+ return [
|
||
+ ...steps.slice(0, limit),
|
||
+ { kind: 'text', text: `… ${steps.length - limit} more step(s)` },
|
||
+ ];
|
||
+};
|
||
+
|
||
+export const isUserMessage = (message: any): boolean =>
|
||
+ !!message &&
|
||
+ typeof message === 'object' &&
|
||
+ message.type === 'message' &&
|
||
+ message.role === 'user';
|
||
+
|
||
+/** How recently an unselected request must have changed to keep being processed. */
|
||
+export const UNSELECTED_ACTIVE_WINDOW_MS = 3 * 60 * 1000;
|
||
+
|
||
+/** Cap on extra requests processed per render, so a busy cache cannot stall the loop. */
|
||
+export const MAX_UNSELECTED_ACTIVE_REQUESTS = 4;
|
||
+
|
||
+/**
|
||
+ * Local, top-level requests that must keep being processed even though they are
|
||
+ * not the selected chat.
|
||
+ *
|
||
+ * The dispatcher only processes `[selectedRequest, ...activeSubAgents]`, so a
|
||
+ * request that a user switches away from - or that a mid-run `initialize_project`
|
||
+ * deselects - has its pending function calls stall forever (see feedback-loop
|
||
+ * Run 4). This returns the ones that are unambiguously still live: changed
|
||
+ * within `freshnessWindowMs` (so an old abandoned chat is never resumed), local
|
||
+ * (never the hosted backend), not suspended/errored, and with a call still to
|
||
+ * process. Pure and injectable for a spec.
|
||
+ */
|
||
+export const getUnselectedActiveRequests = ({
|
||
+ aiRequests,
|
||
+ excludeIds,
|
||
+ getEditorFunctionCallResults,
|
||
+ now,
|
||
+ freshnessWindowMs,
|
||
+ maxCount,
|
||
+}: {|
|
||
+ aiRequests: { [string]: AiRequest },
|
||
+ excludeIds: Set<string>,
|
||
+ getEditorFunctionCallResults: (
|
||
+ aiRequestId: string
|
||
+ ) => Array<EditorFunctionCallResult> | null,
|
||
+ now: number,
|
||
+ freshnessWindowMs: number,
|
||
+ maxCount: number,
|
||
+|}): Array<AiRequest> => {
|
||
+ const result: Array<AiRequest> = [];
|
||
+ const requests = aiRequests || {};
|
||
+ for (const id of Object.keys(requests)) {
|
||
+ if (result.length >= maxCount) break;
|
||
+ if (excludeIds.has(id)) continue;
|
||
+ const request = requests[id];
|
||
+ if (!request || request.parentAiRequestId) continue;
|
||
+ if (typeof id !== 'string' || !id.startsWith('local-ai-')) continue;
|
||
+ if (request.status === 'suspended' || request.status === 'error') continue;
|
||
+ const updatedAt = Date.parse(request.updatedAt || '');
|
||
+ if (!Number.isFinite(updatedAt) || now - updatedAt > freshnessWindowMs) {
|
||
+ continue;
|
||
+ }
|
||
+ const hasPendingCall =
|
||
+ getFunctionCallsToProcess({
|
||
+ aiRequest: request,
|
||
+ editorFunctionCallResults: getEditorFunctionCallResults(id),
|
||
+ }).length > 0 ||
|
||
+ getPendingSubAgentFunctionCalls({ aiRequest: request }).length > 0;
|
||
+ if (!hasPendingCall) continue;
|
||
+ result.push(request);
|
||
+ }
|
||
+ return result;
|
||
+};
|
||
+
|
||
export const getFunctionCallToFunctionCallOutputMap = ({
|
||
aiRequest,
|
||
}: {|
|
||
@@ -73,10 +632,19 @@
|
||
const output = aiRequest.output || [];
|
||
for (let i = 0; i < output.length; i++) {
|
||
const message = output[i];
|
||
+ // Persisted output is only checked for id/status on load and
|
||
+ // normalizePersistedMessages passes non-message entries through, so a
|
||
+ // null hole reaches this loop and its first read threw.
|
||
+ if (!message || typeof message !== 'object') continue;
|
||
|
||
if (message.type === 'message' && message.role === 'assistant') {
|
||
- // Process function calls in this message
|
||
+ // Process function calls in this message. `content` is network-supplied
|
||
+ // and only type-asserted, and this runs on every chat render (the map
|
||
+ // feeds ChatMessages), so a payload without an array would crash the
|
||
+ // chat view itself rather than one field of it.
|
||
+ if (!Array.isArray(message.content)) continue;
|
||
message.content.forEach(content => {
|
||
+ if (!content || typeof content !== 'object') return;
|
||
if (content.type === 'function_call') {
|
||
// Initialize with null output - will be updated if we find a matching output
|
||
functionCallsToOutputs.set(content, null);
|
||
@@ -122,6 +690,7 @@
|
||
const output = aiRequest.output || [];
|
||
for (let i = output.length - 1; i >= 0; i--) {
|
||
const message = output[i];
|
||
+ if (!message || typeof message !== 'object') continue;
|
||
|
||
// Track already processed function call outputs
|
||
if (message.type === 'function_call_output') {
|
||
@@ -130,8 +699,14 @@
|
||
|
||
// Collect function calls that need processing
|
||
if (message.type === 'message' && message.role === 'assistant') {
|
||
+ // Network-supplied and only type-asserted: a missing array would crash
|
||
+ // the caller that builds the function-call list for the chat.
|
||
+ if (!Array.isArray(message.content)) continue;
|
||
const functionCalls = message.content.filter(
|
||
- content => content.type === 'function_call'
|
||
+ content =>
|
||
+ content &&
|
||
+ typeof content === 'object' &&
|
||
+ content.type === 'function_call'
|
||
);
|
||
|
||
if (functionCalls.length > 0) {
|
||
@@ -176,8 +751,11 @@
|
||
const output = aiRequest.output || [];
|
||
for (let i = 0; i < output.length; i++) {
|
||
const message = output[i];
|
||
+ if (!message || typeof message !== 'object') continue;
|
||
if (message.type === 'message' && message.role === 'assistant') {
|
||
+ if (!Array.isArray(message.content)) continue;
|
||
for (const content of message.content) {
|
||
+ if (!content || typeof content !== 'object') continue;
|
||
if (content.type === 'function_call' && content.subAgentAiRequestId) {
|
||
subAgentCalls.push(content);
|
||
}
|
||
@@ -230,6 +808,7 @@
|
||
const output = aiRequest.output || [];
|
||
for (let i = 0; i < output.length; i++) {
|
||
const message = output[i];
|
||
+ if (!message || typeof message !== 'object') continue;
|
||
if (message.type === 'function_call_output') {
|
||
processedCallIds.add(message.call_id);
|
||
}
|
||
@@ -250,8 +829,11 @@
|
||
const output = aiRequest.output || [];
|
||
for (let i = 0; i < output.length; i++) {
|
||
const message = output[i];
|
||
+ if (!message || typeof message !== 'object') continue;
|
||
if (message.type === 'message' && message.role === 'assistant') {
|
||
+ if (!Array.isArray(message.content)) continue;
|
||
for (const content of message.content) {
|
||
+ if (!content || typeof content !== 'object') continue;
|
||
if (content.type === 'function_call' && content.call_id === callId) {
|
||
return content.name;
|
||
}
|
||
@@ -272,11 +854,27 @@
|
||
const outputMessages = aiRequest.output || [];
|
||
for (let i = outputMessages.length - 1; i >= 0; i--) {
|
||
const message = outputMessages[i];
|
||
+ if (!message || typeof message !== 'object') continue;
|
||
if (message.type === 'function_call_output' && message.output) {
|
||
try {
|
||
const output = JSON.parse(message.output);
|
||
- if (output && output.plan && output.plan.tasks) {
|
||
- latestPlan = output.plan;
|
||
+ // `tasks` must be an array, not merely truthy: a non-array (an object,
|
||
+ // a string) satisfies the truthiness test but throws on `.some` below,
|
||
+ // and the try/catch above only covers JSON.parse. A request restored
|
||
+ // from localStorage is not shape-revalidated on load, and the plan
|
||
+ // output is model-authored, so neither the array nor the shape is
|
||
+ // guaranteed by the time this runs.
|
||
+ if (output && output.plan && Array.isArray(output.plan.tasks)) {
|
||
+ // Sanitize here, at the single choke point every consumer reads:
|
||
+ // drop non-object task entries so `tasks.some(task => task.status)`
|
||
+ // below (and OrchestratorPlan's filter, and the studio's scans)
|
||
+ // cannot throw on a hole in the model-authored plan JSON.
|
||
+ latestPlan = {
|
||
+ ...output.plan,
|
||
+ tasks: output.plan.tasks.filter(
|
||
+ task => task && typeof task === 'object'
|
||
+ ),
|
||
+ };
|
||
break;
|
||
}
|
||
} catch (e) {
|
||
@@ -389,13 +987,29 @@
|
||
let hasUnfinishedResult = false;
|
||
const functionCallOutputs = editorFunctionCallResults
|
||
.map(functionCallOutput => {
|
||
+ // Skip malformed entries rather than throwing out of the turn: the
|
||
+ // results array is app-built, but the sibling reads all tolerate a hole.
|
||
+ if (!functionCallOutput || typeof functionCallOutput !== 'object') {
|
||
+ return null;
|
||
+ }
|
||
if (functionCallOutput.status === 'finished') {
|
||
+ // `output: any`: spreading a non-object (a string becomes {0:'a',...},
|
||
+ // an array becomes {0:...}) corrupts the JSON the model receives, so
|
||
+ // only a plain object is spread; anything else is nested under
|
||
+ // `output` so its value survives without numeric keys.
|
||
+ const rawOutput = functionCallOutput.output;
|
||
+ const outputFields =
|
||
+ rawOutput &&
|
||
+ typeof rawOutput === 'object' &&
|
||
+ !Array.isArray(rawOutput)
|
||
+ ? (rawOutput: Object)
|
||
+ : { output: rawOutput };
|
||
return {
|
||
type: 'function_call_output',
|
||
call_id: functionCallOutput.call_id,
|
||
output: JSON.stringify({
|
||
success: functionCallOutput.success,
|
||
- ...functionCallOutput.output,
|
||
+ ...outputFields,
|
||
}),
|
||
};
|
||
}
|
||
@@ -427,15 +1041,21 @@
|
||
|
||
for (let i = output.length - 1; i >= 0; i--) {
|
||
const message = output[i];
|
||
+ if (!message || typeof message !== 'object') continue;
|
||
if (message.type === 'message' && message.role === 'user') {
|
||
- const textContent = message.content.find(c => c.type === 'user_request');
|
||
+ if (!Array.isArray(message.content)) break;
|
||
+ const textContent = message.content.find(
|
||
+ c => c && typeof c === 'object' && c.type === 'user_request'
|
||
+ );
|
||
if (textContent) {
|
||
lastUserMessage = textContent.text;
|
||
}
|
||
break;
|
||
}
|
||
if (message.type === 'message' && message.role === 'assistant') {
|
||
+ if (!Array.isArray(message.content)) continue;
|
||
for (const content of message.content) {
|
||
+ if (!content || typeof content !== 'object') continue;
|
||
if (
|
||
content.type === 'output_text' &&
|
||
lastAssistantMessages.length < 5
|