diff --git a/.agents/skills/nimi-app-acceptance/SKILL.md b/.agents/skills/nimi-app-acceptance/SKILL.md new file mode 100644 index 0000000000..1a6e963e3c --- /dev/null +++ b/.agents/skills/nimi-app-acceptance/SKILL.md @@ -0,0 +1,22 @@ +--- +name: nimi-app-acceptance +description: Run Desktop-supervised Nimi App acceptance or debug an App renderer through its exact CDP target. Use when the task requires real App interaction or renderer observation. +--- + +# Nimi App acceptance + +Verify the requested behavior in the real supported App. Start from the affected consumer and its direct contract; use the applicable AGENTS.md chain and the App's package manifest to choose the existing launch command. + +## Launch and target + +- Launch through the guarded package script with Desktop-supervised Electron. Attach to the exact App named by the task; do not open its Vite renderer directly or attach to Desktop or another App as a substitute. +- Generic `nimi-app dev` selects and prints an ephemeral loopback CDP port. When a stable port is needed, use `pnpm --filter dev -- --cdp-port ` with the actual package and an available port. +- Root `pnpm dev:desktop`, `dev:zhiyu`, `dev:lab`, and `dev:avatar` own deterministic default ports. Generic dev may read the exact `NIMI_APP_DEV_CDP_PORT` override from the project `.env`; `--cdp-port` overrides either path and `--no-cdp` disables CDP. +- Keep CDP loopback-only, development-only, and ephemeral. Native and owner UI remain outside CDP; use their supported owner paths when the task requires them. + +## Observe and complete + +- Reproduce the reported behavior or exercise the newly requested interaction, repair failures within scope, and rerun the affected journey. A missing prerequisite blocks only dependent work. +- Use the existing browser/CDP tooling for observation. Do not add alternative CDP defaults, acceptance harnesses, Playwright projects, helper endpoints, recordings, fixtures, baselines, or evidence systems. +- Run checks that cover the changed behavior. CDP visibility, screenshots, and fixture output do not establish the full product result. +- Report the observed outcome and mark relevant unexecuted paths `NOT-VERIFIED`. Put any necessary local artifacts under `.nimi/local/**`. diff --git a/.agents/skills/nimi-authority-work/SKILL.md b/.agents/skills/nimi-authority-work/SKILL.md new file mode 100644 index 0000000000..6bf1ee228d --- /dev/null +++ b/.agents/skills/nimi-authority-work/SKILL.md @@ -0,0 +1,13 @@ +--- +name: nimi-authority-work +description: Author or review Nimi canonical authority changes and diagnose authority-command failures. Use for .authority.yaml/.authority.md work, not ordinary implementation that follows settled contracts. +--- + +# Nimi authority work + +Use the project's existing authoring workflow; the host owns task state and product decisions. + +- Read the [authority authoring guide](../../../.nimi/methodology/authority-authoring.yaml) for the relevant operation. Reuse task authority and already sufficient context; do not preload the authority corpus or package-internal specifications. +- Invoke Nimi-coding from the repository root through `pnpm exec nimicoding`. Follow the guide's complete-input, query-budget, format, validation, and semantic-change requirements for the operation actually needed. +- Treat query scope and declared impact as bounded evidence, not proof of implementation conformance or task completion. Missing, refused, or incomplete results pause dependent decisions; continue independent authorized work without inventing replacement context. +- Keep product meaning in `.nimi/spec/**`. Local reports and generated results remain non-authoritative; finish with the actual change and validation outcome. diff --git a/.claude/hooks/inject-agents-md.sh b/.claude/hooks/inject-agents-md.sh deleted file mode 100755 index 8dbff70259..0000000000 --- a/.claude/hooks/inject-agents-md.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash -# PreToolUse hook: auto-inject nearest module AGENTS.md into Claude's context -# when Read, Edit, or Write is called on files within this repo. -set -euo pipefail - -INPUT=$(cat) - -FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') -if [[ -z "$FILE_PATH" ]]; then - exit 0 -fi - -# Repo root = two levels up from this script (.claude/hooks/ -> repo root) -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" - -# Only handle files inside this repo -if [[ "$FILE_PATH" != "$REPO_ROOT/"* ]]; then - exit 0 -fi - -# Skip _external directory -if [[ "$FILE_PATH" == *"/_external/"* ]]; then - exit 0 -fi - -# Walk up from file directory to find nearest module-level AGENTS.md -# Stop before repo root (root AGENTS.md is already synced into CLAUDE.md) -DIR="$(dirname "$FILE_PATH")" -AGENTS_FILE="" -while [[ "$DIR" != "$REPO_ROOT" && "$DIR" != "/" ]]; do - if [[ -f "$DIR/AGENTS.md" ]]; then - AGENTS_FILE="$DIR/AGENTS.md" - break - fi - DIR="$(dirname "$DIR")" -done - -if [[ -z "$AGENTS_FILE" ]]; then - exit 0 -fi - -# Make path relative for readability -REL_PATH="${AGENTS_FILE#$REPO_ROOT/}" - -CONTENT=$(cat "$AGENTS_FILE") - -# Inject AGENTS.md content as additionalContext -jq -n \ - --arg ctx "[Auto-injected module rules from $REL_PATH]"$'\n\n'"$CONTENT" \ - '{ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "additionalContext": $ctx - } - }' diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 2ab03d06f8..0000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Read|Edit|Write", - "hooks": [ - { - "type": "command", - "command": ".claude/hooks/inject-agents-md.sh" - } - ] - } - ] - } -} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5aecb1a12d..ee6be844f2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,7 +5,9 @@ This file is a compatibility entry for Copilot. Authoritative project rules are defined in: 1. [`AGENTS.md`](../AGENTS.md) -2. Path-scoped `*/AGENTS.md` files +2. Every `AGENTS.md` on the path from the repository root to the target, including intermediate ancestors + +Read guidance when entering its scope and reuse it while unchanged; do not enumerate unrelated subtrees. If this file and any `AGENTS.md` content conflict, `AGENTS.md` always wins. Do not invent tool-specific workflow rules outside the AGENTS hierarchy. diff --git a/.gitignore b/.gitignore index d5d9c88d11..bb4feacdf9 100644 --- a/.gitignore +++ b/.gitignore @@ -48,9 +48,6 @@ coverage/ .ruff_cache/ _external/ -.claude/settings.local.json -.claude/launch.json -**/.claude/launch.json /$HOME/ # legacy execution-doc roots are archive/local-only @@ -95,7 +92,6 @@ venv/ env/ ENV/ -.claude/scheduled_tasks.lock .playwright-cli/ .codex-temp/ outputs/ diff --git a/.nimi/spec/platform/core-protocol.authority.yaml b/.nimi/spec/platform/core-protocol.authority.yaml index e04aa0a12d..1454e539d5 100644 --- a/.nimi/spec/platform/core-protocol.authority.yaml +++ b/.nimi/spec/platform/core-protocol.authority.yaml @@ -390,7 +390,7 @@ units: kind: rule title: P-PROTO-047 scenario consumption App operation family modality: must - statement: runtime.ai.text-turn.stream, runtime.ai.scenario.execute, runtime.ai.scenario-job.submit, runtime.ai.scenario-job.get, runtime.ai.scenario-job.subscribe, runtime.ai.scenario-job.cancel, runtime.ai.artifact.read, runtime.ai.artifact.upload, runtime.ai.artifact.adopt-to-app-storage, and runtime.ai.voice-assets.list are runtime.consume operations that consume the calling App owner's committed AIConfig without caller-selected route, provider, model, target, connector, credential, grant, tool, background authority, allowed-use selector, or fallback; Local App Job submit captures a Runtime-private immutable owner composed only of current AccountID plus Registered App Subject, and get, subscribe, cancel, artifact metadata, and bounded typed transcription text projection authorize that owner while AppID remains non-authorizing producer or catalog metadata; artifact read, adoption, and artifact-reference scenario input use one owner authorizer over the same tuple and exact Runtime-selected typed operation, historical subject-unbound Jobs and artifacts fail closed without AppID fallback, absent, foreign-owner, and subject-unbound artifact read or artifact-ref identifiers return one non-disclosing forbidden result, and adoption collapses missing, foreign, unbound, expired, and corrupt sources to one artifact-unavailable result without target mutation; runtime.ai.artifact.read returns at most 32 MiB of inline bytes, runtime.ai.artifact.upload preserves its bounded inline contract, scenario-specific inline audio bytes are admitted or rejected by their exact typed byte cap rather than a generic per-byte structural-node count, streamed custody, adoption, metadata, and Base asset streaming do not use that ceiling, and every response returns only the operation-specific typed projection with trace correlation. + statement: runtime.ai.text-turn.stream, runtime.ai.scenario.execute, runtime.ai.scenario-job.submit, runtime.ai.scenario-job.get, runtime.ai.scenario-job.subscribe, runtime.ai.scenario-job.cancel, runtime.ai.artifact.read, runtime.ai.artifact.upload, runtime.ai.artifact.adopt-to-app-storage, and runtime.ai.voice-assets.list are runtime.consume operations that consume the calling App owner's committed AIConfig without caller-selected route, provider, model, target, connector, credential, grant, background authority, allowed-use selector, or fallback; Local App Job submit captures a Runtime-private immutable owner composed only of current AccountID plus Registered App Subject, and get, subscribe, cancel, artifact metadata, and bounded typed transcription text projection authorize that owner while AppID remains non-authorizing producer or catalog metadata; artifact read, adoption, and artifact-reference scenario input use one owner authorizer over the same tuple and exact Runtime-selected typed operation, historical subject-unbound Jobs and artifacts fail closed without AppID fallback, absent, foreign-owner, and subject-unbound artifact read or artifact-ref identifiers return one non-disclosing forbidden result, and adoption collapses missing, foreign, unbound, expired, and corrupt sources to one artifact-unavailable result without target mutation; runtime.ai.artifact.read returns at most 32 MiB of inline bytes, runtime.ai.artifact.upload preserves its bounded inline contract, scenario-specific inline audio bytes are admitted or rejected by their exact typed byte cap rather than a generic per-byte structural-node count, streamed custody, adoption, metadata, and Base asset streaming do not use that ceiling, and every response returns only the operation-specific typed projection with trace correlation. The text.generate variant of runtime.ai.scenario.execute and runtime.ai.text-turn.stream additionally carry bounded function ToolSpec, ordered text and ToolCall output, later external-host ToolResult transcript, exact tool choice, and structured response format through the existing operation identities; these are model input and output values rather than permission or workflow authority, while provider tools, raw provider fields, tool execution and multi-turn state remain outside Runtime App consumption. Successful text.embed output also carries a Runtime-issued opaque space_id for vector compatibility, derived from the inputs captured for that execution and never usable as a route, model, configuration or authorization selector. Exact text adapters may carry bounded opaque ReasoningContinuityCarrier values for stateless tool-result round trips through the same typed text operations; the external Host preserves these values without inspecting or displaying them, and this exception exposes no raw reasoning or new authority selector. condition: Whenever one of the ten scenario-consumption App operations is invoked or a Local App Job or artifact consumer is authorized. failure: Deny the exact operation on missing runtime.consume coverage, invalid bounded input, unsupported artifact upload MIME, cross-owner or historical-unbound Job or artifact access, oversized inline artifact read or upload, owner AIConfig composition failure, or execution failure, return the admitted non-disclosing public result for artifact lookup or adoption, and never expose route, provider, model, account, subject, connector, credential, grant, or internal owner facts in any request or response field. relations: diff --git a/.nimi/spec/runtime/ai-provider.authority.yaml b/.nimi/spec/runtime/ai-provider.authority.yaml index 4c0f81624d..03b192ba01 100644 --- a/.nimi/spec/runtime/ai-provider.authority.yaml +++ b/.nimi/spec/runtime/ai-provider.authority.yaml @@ -1107,7 +1107,7 @@ units: kind: rule title: Local App scenario consumption composes App AIConfig modality: must - statement: The runtime.ai.text-turn.stream, runtime.ai.scenario.execute, runtime.ai.scenario-job.submit, runtime.ai.scenario-job.get, runtime.ai.scenario-job.subscribe, runtime.ai.scenario-job.cancel, runtime.ai.artifact.read, runtime.ai.artifact.upload, runtime.ai.artifact.adopt-to-app-storage, and runtime.ai.voice-assets.list Local App operations accept only bounded closed-set scenario specifications, Job identifiers, artifact identifiers, the admitted upload body, asset adoption destination path and overwrite intent, and catalog page controls; Runtime admits each exact operation only for a current protected App session whose Effective App Access Snapshot covers runtime.consume, derives current AccountID and Registered App Subject from that session and operation classification from typed protected ingress, treats AppID only as producer or catalog metadata, and for Local Job submission atomically dereferences the current machine-selected Loadout for the calling App owner's route-only Local CapabilityContract, revalidates its model contents, and persists its exact identity and complete ResolvedAssembly before publication, or for Cloud captures that owner's exact committed Connector reference, CapabilityImplementation, and Driver target. Runtime validates selected-resource ownership, credential configuration, App access, and existing policy, returns the existing owner, configuration-required, resource, credential, access-denied, or policy reason on failure without accepting Job-time resource selection, and Local App Job submit captures an immutable Runtime-private owner containing current AccountID plus Registered App Subject; get, subscribe, cancel, artifact metadata, and bounded validated transcription_text authorize and project only that owner, historical Jobs without subject binding fail, and no ScenarioRequestHead accepts the subject. The Local App trimmed artifact surface exposes no free-form metadata and may expose only an optional typed image seed copied from the admitted image result; it is absent for non-image artifacts. Artifact read, adoption, and artifact-reference input share one authorizer over the same tuple and the exact Runtime-selected operation, artifact read alone returns complete inline bytes at or below 32 MiB, metadata and streamed custody are body-free and uncapped by that inline ceiling, provider media commits before reference, and requests and responses contain no account, subject, principal, Agent, provider, model, route, target, connector, credential, grant, allowed-use selector, tool, raw-chunk, reasoning-trace, provider URL, or background-execution field. vision.locate uses this existing operation family for its owned image artifact, bounded query, and geometry, returns its operation-specific success result only through terminal Get, and never adds an App operation or use declaration. Local App Job snapshots preserve Runtime's typed interruption cause and caller-resubmit disposition as owner result facts without exposing owner identity, accepting caller-reported interruption, or automatically resubmitting work. Face replacement uses the same owned artifact and ScenarioJob operation family for bounded JPEG or PNG reference images, image targets and video/mp4 targets; video upload remains one protected 32-MiB operation, not a second upload or storage system. The separate video.face_swap Session form requires its own admitted closed operation mapping before public execution. Local App music.generate admits prompt, lyrics, and an optional whole-second duration budget from 1 through 180; absent or zero wire duration delegates to Runtime defaults. The local MiniMax-Music3 audio.cpp Driver maps this budget to duration_sec with a 20-second default, rejects larger budgets, and never claims an exact song length or prior-audio continuation. The 180-second App bound keeps this stereo 44.1-kHz PCM output within the existing 32-MiB inline artifact path. + statement: The runtime.ai.text-turn.stream, runtime.ai.scenario.execute, runtime.ai.scenario-job.submit, runtime.ai.scenario-job.get, runtime.ai.scenario-job.subscribe, runtime.ai.scenario-job.cancel, runtime.ai.artifact.read, runtime.ai.artifact.upload, runtime.ai.artifact.adopt-to-app-storage, and runtime.ai.voice-assets.list Local App operations accept only bounded closed-set scenario specifications, Job identifiers, artifact identifiers, the admitted upload body, asset adoption destination path and overwrite intent, and catalog page controls; Runtime admits each exact operation only for a current protected App session whose Effective App Access Snapshot covers runtime.consume, derives current AccountID and Registered App Subject from that session and operation classification from typed protected ingress, treats AppID only as producer or catalog metadata, and for Local Job submission atomically dereferences the current machine-selected Loadout for the calling App owner's route-only Local CapabilityContract, revalidates its model contents, and persists its exact identity and complete ResolvedAssembly before publication, or for Cloud captures that owner's exact committed Connector reference, CapabilityImplementation, and Driver target. Runtime validates selected-resource ownership, credential configuration, App access, and existing policy, returns the existing owner, configuration-required, resource, credential, access-denied, or policy reason on failure without accepting Job-time resource selection, and Local App Job submit captures an immutable Runtime-private owner containing current AccountID plus Registered App Subject; get, subscribe, cancel, artifact metadata, and bounded validated transcription_text authorize and project only that owner, historical Jobs without subject binding fail, and no ScenarioRequestHead accepts the subject. The Local App trimmed artifact surface exposes no free-form metadata and may expose only an optional typed image seed copied from the admitted image result; it is absent for non-image artifacts. Artifact read, adoption, and artifact-reference input share one authorizer over the same tuple and the exact Runtime-selected operation, artifact read alone returns complete inline bytes at or below 32 MiB, metadata and streamed custody are body-free and uncapped by that inline ceiling, provider media commits before reference, and requests and responses contain no account, subject, principal, Agent, provider, model, route, target, connector, credential, grant, allowed-use selector, raw-chunk, reasoning-trace, provider URL, or background-execution field. vision.locate uses this existing operation family for its owned image artifact, bounded query, and geometry, returns its operation-specific success result only through terminal Get, and never adds an App operation or use declaration. Local App Job snapshots preserve Runtime's typed interruption cause and caller-resubmit disposition as owner result facts without exposing owner identity, accepting caller-reported interruption, or automatically resubmitting work. Face replacement uses the same owned artifact and ScenarioJob operation family for bounded JPEG or PNG reference images, image targets and video/mp4 targets; video upload remains one protected 32-MiB operation, not a second upload or storage system. The separate video.face_swap Session form requires its own admitted closed operation mapping before public execution. Local App music.generate admits prompt, lyrics, and an optional whole-second duration budget from 1 through 180; absent or zero wire duration delegates to Runtime defaults. The local MiniMax-Music3 audio.cpp Driver maps this budget to duration_sec with a 20-second default, rejects larger budgets, and never claims an exact song length or prior-audio continuation. The 180-second App bound keeps this stereo 44.1-kHz PCM output within the existing 32-MiB inline artifact path. The synchronous text.generate scenario and streaming text turn admit the bounded function-tool and structured-output subset defined by Local App text behavior, reusing the exact text behavior adapter and ordered item contracts; all other scenario variants retain their closed inputs and never acquire tool execution or workflow authority. condition: Whenever a third-party Local App executes a synchronous scenario, submits, observes, or cancels a scenario Job, reads, uploads, adopts, or references a scenario artifact, lists voice assets, or streams a text turn. failure: Reject a missing or non-current protected App session, missing runtime.consume coverage, unbounded or out-of-closed-set input, unsupported artifact upload MIME, cross-owner or historical-unbound Job or artifact access, oversized inline artifact read or upload, absent or inadmissible owner AIConfig composition, and every caller-selected owner, use, route, or generic AI payload without forcing Local, changing the committed route, fallback, synthesized output, or an existence oracle; adoption returns the single broader artifact-unavailable result without target mutation. relations: @@ -1227,8 +1227,8 @@ units: kind: rule title: Text Behavior adapter mapping is exact before execution modality: must - statement: Each versioned private Text Behavior adapter registration names its adapter id and version, exact model and template compatibility, applicable recipeId and Driver dialect, request serializer, non-stream response parser, stream fragment assembler, required fixed template or RecipeCustody, load-affecting options, supported Tool Use, Reasoning, Structured Output, and combination matrix, unsupported typed reasons, and whether adapter or template identity affects process identity. Driver registration and the recipe-specific Model Contract prove that exact relation before Loadout projection; after base text validation zero matches expose base text with the behavior unavailable, one match configures exactly the declared behavior set, and multiple matches expose ambiguity and reject behavior requests, with the whole Loadout blocked only when competing fixed template, loader, custody, or load-affecting process semantics make base execution itself uninterpretable. Catalog may recommend a model and adapter cohort but owns no execution mapping, and Runtime never calls props, sends a trial prompt, mines prior success, guesses from a label or filename, dynamically promotes support, or falls back after failure. - condition: Whenever a text Loadout is prepared, committed, projected, selected, or admitted for optional typed behavior. + statement: Each versioned private Text Behavior adapter registration names its adapter id and version, exact model and template compatibility, applicable recipeId and Driver dialect, request serializer, non-stream response parser, stream fragment assembler, required fixed template or RecipeCustody, load-affecting options, supported Tool Use, Reasoning, Structured Output, and combination matrix, unsupported typed reasons, and whether adapter or template identity affects process identity. Driver registration and the recipe-specific Model Contract prove that exact relation before Loadout projection; after base text validation zero matches expose base text with the behavior unavailable, one match configures exactly the declared behavior set, and multiple matches expose ambiguity and reject behavior requests, with the whole Loadout blocked only when competing fixed template, loader, custody, or load-affecting process semantics make base execution itself uninterpretable. Catalog may recommend a model and adapter cohort but owns no execution mapping, and Runtime never calls props, sends a trial prompt, mines prior success, guesses from a label or filename, dynamically promotes support, or falls back after failure. An adapter may explicitly declare the plain-text combination with no optional behavior for exact execution modes; that target then uses the same captured hooks and uniqueness checks for those requests, while targets without that declaration retain their admitted base path. + condition: Whenever a text execution target is prepared, committed, projected, selected, or admitted for plain text or optional typed behavior. failure: Preserve exact base validity, return AI_TEXT_BEHAVIOR_UNSUPPORTED for unavailable or unsupported behavior and AI_TEXT_BEHAVIOR_AMBIGUOUS for multiple matches, or fail the precise fixed base conflict without adapter inference, public adapter selection, route change, or fallback. relations: - type: applies_to @@ -1403,3 +1403,53 @@ units: target: definition.nimi.platform.core-protocol.app-operation-contract - type: applies_to target: definition.nimi.runtime.ai-provider.face-swap-contract + - id: rule.nimi.runtime.ai-provider.local-app-text-behaviors + kind: rule + title: Local App text behaviors reuse single-turn Scenario execution + modality: must + statement: The text.generate variant of ExecuteLocalAppScenario and StreamLocalAppTextTurn share one bounded text specification containing system, user, and assistant text or canonical ordered assistant turn items, function-only ToolSpec, ToolChoiceMode with optional exact name, ResponseFormat, and existing sampling controls; the request is bounded to 128 messages, 64 declared function tools and 1 MiB of serialized input, and output to 256 KiB of text and complete tool-call content. Candidate generation retains its narrower existing contract. Runtime derives identity and committed App AIConfig from the current exact protected session, validates the existing versioned text behavior adapter and ordered ToolCall/ToolResult transcript, and returns only ordered text or complete ToolCall items, finish reason and trace correlation; private provider metadata, dynamic or provider-owned tools, raw reasoning and reasoning-summary payloads, media, raw chunks, caller identity, routing and credentials are rejected. An exact adapter may additionally return and accept the existing self-contained ReasoningContinuityCarrier, bounded to 64 KiB per payload and included in the existing total byte budgets, solely as opaque stateless transcript data; it is not a reasoning-control surface or authorization and cannot by itself make a model step successful. Schema documents and ToolResult values are inert business JSON whose property names never select caller identity or routing; the protected text ingress validates surrounding typed fields and unknown protobuf fields while permitting those exact JSON data positions. Tool-only and mixed text/tool output are legal, streamed calls are emitted only complete and schema-valid, terminal success seals the model step, and structured output uses the existing captured schema validation without repair or fallback. The external AI host alone executes tools and owns later turns, pending execution, results, cancellation, business failure and reconnect projections; Runtime stores no cross-request tool run or agent loop and a terminated model call cannot continue background execution on behalf of a stopped host. + condition: Whenever a formal Local App consumes function tools or structured text through the existing execute or text-stream operation. + failure: Reject invalid bounded input or a forbidden field with AI_INPUT_INVALID, invalid calls or result associations with AI_TOOL_CALL_INVALID, unsupported adapter behavior with AI_TEXT_BEHAVIOR_UNSUPPORTED, and invalid or incomplete output with AI_OUTPUT_INVALID; preserve exact session, configuration and interruption failures without introducing a generic proxy, new operation identity, hidden provider selection, automatic tool execution or workflow recovery. + relations: + - type: applies_to + target: definition.nimi.platform.core-protocol.ai-config + - type: applies_to + target: definition.nimi.platform.core-protocol.app-operation-contract + - type: applies_to + target: definition.nimi.runtime.ai-provider.multimodal-provider-plane + - id: rule.nimi.runtime.ai-provider.embedding-space-identity + kind: rule + title: Embedding results identify their captured vector space + modality: must + statement: Each successful TEXT_EMBED Scenario execution returns a nonempty opaque space_id with its vectors, including the protected Local App projection. Runtime derives this compatibility value from the exact captured embedding implementation and versioned dialect, Local recipe and bound content with effective options or the exact Cloud Connector resource and provider target with effective defaults, and the returned vector dimensions. Input text, batch boundaries, AIConfig owner revision, other capabilities, display labels, transient process state and filesystem locations do not identify a vector space. Equal values permit a consumer to combine vectors under the same captured embedding semantics; changed values require rebuilding or separating its index. The value conveys no execution, configuration or authorization authority and exposes no provider, model, route, credential, account or private assembly fields; it is not a Loadout revision or an attestation of unannounced provider-side behavior. Consumers compare values from actual embedding results, so a configuration change between batches cannot silently mix an index. + condition: Whenever a text.embed Scenario result is returned or its vectors are persisted or reused by a consumer. + failure: Return AI_OUTPUT_INVALID rather than successful embedding output without a valid space_id; consumers reject mixed spaceIds and never infer compatibility solely from vector length or current configuration projections. + relations: + - type: applies_to + target: definition.nimi.runtime.ai-provider.capability-execution-composition-plane + - type: applies_to + target: definition.nimi.runtime.ai-provider.multimodal-provider-plane + - id: rule.nimi.runtime.ai-provider.anthropic-sonnet46-text-behaviors + kind: rule + title: The first Cloud text adapter uses exact Anthropic Messages semantics + modality: must + statement: The first admitted Cloud text behavior slice matches provider anthropic and provider model target claude-sonnet-4-6 with the existing AIConfig implementation tuple anthropic, nimillm, anthropic. Its versioned Anthropic Messages hooks serialize text-only canonical messages and ordered function ToolCall and ToolResult turns, preserve ordered text and tool_use content blocks, and admit auto, none, required and named tool choice with single or parallel calls through synchronous and streaming Scenario execution. Structured output uses the native output_config.format JSON Schema contract and validates final JSON against the captured schema; unsupported schema constraints fail before dispatch without deleting constraints or substituting a repair prompt. A stream publishes only complete schema-valid tool arguments, requires message_stop and a valid stop reason, and fails incomplete or error output without converting transport closure into success. The slice does not admit reasoning, provider-executed tools, media, asynchronous behavior Jobs or combined tools and structured output. Credential custody, exact endpoint composition, scheduling and metering remain with the existing Runtime owners, while only the external Host executes tools and chooses subsequent turns; registering a provider or catalog row does not implicitly admit another model or protocol. + condition: Whenever the exact Claude Sonnet 4.6 target consumes Tool Use or Structured Output through a Cloud text Scenario. + failure: Reject a different or ambiguous adapter target and unsupported behavior with AI_TEXT_BEHAVIOR_UNSUPPORTED, malformed input with AI_INPUT_INVALID, invalid tool arguments with AI_TOOL_CALL_INVALID, and malformed or unterminated provider output with AI_OUTPUT_INVALID or AI_TEXT_OUTPUT_INCOMPLETE; preserve provider and cancellation failures without another model, protocol or workflow fallback. + relations: + - type: applies_to + target: definition.nimi.runtime.ai-provider.capability-execution-composition-plane + - type: applies_to + target: definition.nimi.runtime.ai-provider.multimodal-provider-plane + - id: rule.nimi.runtime.ai-provider.codex-text-behaviors + kind: rule + title: Codex function tools use one captured Responses adapter + modality: must + statement: The Codex text behavior slice matches provider openai_codex and the exact model targets gpt-5.6-sol and gpt-6-astra with the existing AIConfig tuple openai_codex, nimillm, openai_codex. Both plain text and admitted optional behavior use the same captured versioned Responses hooks; no-tool requests do not switch to a separate primitive protocol. It maps text-only canonical input, function tools, auto/none/required/named choice and ordered ToolCall/ToolResult transcript to Responses function_call and function_call_output items, uses store false and a complete caller-preserved stateless transcript, and consumes SSE for both synchronous collection and public streaming without a stream-to-JSON fallback. Native JSON Schema uses text.format and validates the completed value against the captured schema; unsupported controls or schema constraints fail before dispatch rather than being erased. The adapter requests and preserves only the encrypted reasoning continuity needed for tool round trips through the existing bounded ReasoningContinuityCarrier and can support that relay without exposing reasoning activation, summary or raw thinking; carrier-only output is never success. A completed response and complete schema-valid call arguments are required before a model step succeeds. Other Codex targets, hosted tools, media, async behavior Jobs, caller-supplied response ids and combined tools plus structured-output requests are outside this slice. Runtime keeps endpoint, credentials, scheduling and metering ownership while the external Host alone executes tools and owns the next request. + condition: Whenever either admitted Codex subscription target consumes plain text, tools, structured output or their opaque continuity through text Scenario operations. + failure: Reject unsupported behavior with AI_TEXT_BEHAVIOR_UNSUPPORTED and invalid input with AI_INPUT_INVALID; reject malformed calls, inconsistent item association, missing response completion, provider error or invalid structured output with the corresponding typed failure, preserving cancellation and without another model, provider, protocol or workflow fallback. + relations: + - type: applies_to + target: definition.nimi.runtime.ai-provider.capability-execution-composition-plane + - type: applies_to + target: definition.nimi.runtime.ai-provider.multimodal-provider-plane diff --git a/.nimi/spec/sdks/feature-clients.authority.yaml b/.nimi/spec/sdks/feature-clients.authority.yaml index d94e1abf51..b985546b91 100644 --- a/.nimi/spec/sdks/feature-clients.authority.yaml +++ b/.nimi/spec/sdks/feature-clients.authority.yaml @@ -848,7 +848,7 @@ units: kind: rule title: Local App scenario consumption is a bounded typed operation family modality: must - statement: The host-injected App client exposes only bounded typed methods for runtime.ai.text-turn.stream, runtime.ai.scenario.execute, runtime.ai.scenario-job.submit, runtime.ai.scenario-job.get, runtime.ai.scenario-job.subscribe, runtime.ai.scenario-job.cancel, runtime.ai.artifact.read, runtime.ai.artifact.upload, runtime.ai.artifact.adopt-to-app-storage, and runtime.ai.voice-assets.list while the central App Operation Contract maps each to runtime.consume and Runtime alone admits a call when its internal Effective App Access Snapshot covers runtime.consume; scenario Job and artifact projections remain current-account-plus-Registered-App-Subject-bound without exposing that owner, getScenarioArtifacts returns metadata without automatically calling runtime.ai.artifact.read or applying the 32-MiB inline ceiling, explicit inline read remains capped, speech-transcribe submit, get, and subscribe map immutable Runtime transcription_text to transcriptionText without reopening artifact bytes, and voice.create submit returns only the Job while subscribe projects Job-only events and the SDK calls get once after a COMPLETED event to project the VoiceAsset and VoiceReference terminal result; text-conditioned world.generate is admitted only as an async Job whose Local App result is a portable application/vnd.nimi.world+zip artifact containing world.json, a SPZ scene, available collider and preview assets, and explicit coordinate-frame and metric-scale metadata, with provider asset retrieval and packaging owned by Runtime and provider URLs excluded from the Local App archive; each method accepts only its bounded typed input and returns only its operation-specific projection, and the client never exposes the snapshot, ExecuteScenario, SubmitScenarioJob, a Runtime client, method names, protobuf bytes, model or provider selection, route or target refs, allowed-use selectors, tools, credentials, account authority, provider URLs, or generic forwarding. For vision.locate the same upload and Job methods carry the bounded image-artifact, query, and geometry input and Runtime's typed terminal location result; the runner reuses an already obtained terminal Get response or gets it after COMPLETED, validates the result, and skips an unrelated artifact query or read. Native carrier, Host, renderer, and SDK typed projections preserve the existing interruption cause and caller-resubmit disposition, including when a terminal Job becomes an SDK error, without parsing diagnostic text, adding caller-supplied fields, or automatically retrying. image.face_swap and finite video.face_swap use this same typed upload and ScenarioJob family; the existing protected upload MIME union adds video/mp4 while retaining the 32-MiB body bound and exact owner derivation, and large returned artifacts use existing adoptArtifact and streamed asset read. The SDK exposes no App asset path as a face-replacement input alternative. The protected music.generate input and shared Kit music runner carry optional durationSeconds as an integer from 1 through 180 through the typed carrier to Runtime, reject invalid values before transport, and describe it as a generation budget rather than guaranteed duration; prompt and lyrics remain required and audio-reference inputs remain unavailable. + statement: The host-injected App client exposes only bounded typed methods for runtime.ai.text-turn.stream, runtime.ai.scenario.execute, runtime.ai.scenario-job.submit, runtime.ai.scenario-job.get, runtime.ai.scenario-job.subscribe, runtime.ai.scenario-job.cancel, runtime.ai.artifact.read, runtime.ai.artifact.upload, runtime.ai.artifact.adopt-to-app-storage, and runtime.ai.voice-assets.list while the central App Operation Contract maps each to runtime.consume and Runtime alone admits a call when its internal Effective App Access Snapshot covers runtime.consume; scenario Job and artifact projections remain current-account-plus-Registered-App-Subject-bound without exposing that owner, getScenarioArtifacts returns metadata without automatically calling runtime.ai.artifact.read or applying the 32-MiB inline ceiling, explicit inline read remains capped, speech-transcribe submit, get, and subscribe map immutable Runtime transcription_text to transcriptionText without reopening artifact bytes, and voice.create submit returns only the Job while subscribe projects Job-only events and the SDK calls get once after a COMPLETED event to project the VoiceAsset and VoiceReference terminal result; text-conditioned world.generate is admitted only as an async Job whose Local App result is a portable application/vnd.nimi.world+zip artifact containing world.json, a SPZ scene, available collider and preview assets, and explicit coordinate-frame and metric-scale metadata, with provider asset retrieval and packaging owned by Runtime and provider URLs excluded from the Local App archive; each method accepts only its bounded typed input and returns only its operation-specific projection, and the client never exposes the snapshot, ExecuteScenario, SubmitScenarioJob, a Runtime client, method names, protobuf bytes, model or provider selection, route or target refs, allowed-use selectors, credentials, account authority, provider URLs, or generic forwarding. For vision.locate the same upload and Job methods carry the bounded image-artifact, query, and geometry input and Runtime's typed terminal location result; the runner reuses an already obtained terminal Get response or gets it after COMPLETED, validates the result, and skips an unrelated artifact query or read. Native carrier, Host, renderer, and SDK typed projections preserve the existing interruption cause and caller-resubmit disposition, including when a terminal Job becomes an SDK error, without parsing diagnostic text, adding caller-supplied fields, or automatically retrying. image.face_swap and finite video.face_swap use this same typed upload and ScenarioJob family; the existing protected upload MIME union adds video/mp4 while retaining the 32-MiB body bound and exact owner derivation, and large returned artifacts use existing adoptArtifact and streamed asset read. The SDK exposes no App asset path as a face-replacement input alternative. The protected music.generate input and shared Kit music runner carry optional durationSeconds as an integer from 1 through 180 through the typed carrier to Runtime, reject invalid values before transport, and describe it as a generation budget rather than guaranteed duration; prompt and lyrics remain required and audio-reference inputs remain unavailable. The text.generate execute variant and text-turn stream additionally expose the admitted Local App function tools, ordered text and complete ToolCall items, later host-supplied ToolResult transcript, tool choice and structured response format; SDK model binding reuses the common AI value contracts and the standard App carrier without returning a Runtime client or owning multi-turn state. Public text.embed results preserve the Runtime-issued opaque spaceId beside their vectors; SDK and carrier reject an absent spaceId, and consumers compare actual result spaceIds before combining batches or reusing a document index instead of deriving vector compatibility from AIConfig revision, Loadout identity, display labels or vector dimensions alone. condition: Whenever a Local App executes a scenario, submits, observes, or cancels a scenario Job, streams a text turn, reads, uploads, adopts, or references an artifact, or lists voice assets through the SDK. failure: Fail closed before transport on invalid input, unsupported upload MIME, oversized inline input, absent host carrier, cross-owner or historical-unbound lookup, or artifact-unavailable adoption and preserve Runtime admission and execution failures without fabricating scenario output, transcription text, pre-terminal voice results, artifact bytes, custody, location results, or interruption facts, and reject missing or dropped required terminal fields. relations: @@ -1020,3 +1020,17 @@ units: target: definition.nimi.runtime.ai-provider.face-swap-contract - type: applies_to target: definition.nimi.sdks.feature-clients.realtime-plane + - id: rule.nimi.sdks.feature-clients.local-app-text-behaviors + kind: rule + title: Local App AI model binding preserves common tool and structured values + modality: must + statement: The TypeScript SDK binds a NimiAiModel to the formal Local App text-generate execute variant and text-turn stream through the host-injected standard shell, using the existing capability-only text.generate model reference and common NimiMessage, function tool, ordered output item, ToolCall, ToolResult, response format and finish-reason semantics without an App-selected provider or model. The binding validates the admitted text-only subset before transport, rejects provider metadata and execution fields, preserves complete tool calls and their order, rejects stream gaps and missing terminal success, and propagates cancellation to its active carrier. The model binding generateText collects one declared text-turn stream so its AbortSignal can close the active carrier, while callers requiring the separately admitted synchronous transport use the scenario execute variant explicitly; unsupported execution-mode behavior remains an explicit failure and never selects a different adapter. Tool execution helpers operate solely inside the external AI host, preserve call/result association despite out-of-order completion, dispatch no partial or unterminated model batch, stop on missing handlers or lost execution state, and report their own business failure or cancellation without claiming a Runtime-owned cross-request run. Success, tool error and canceled effects remain host-owned typed results mapped to canonical ToolResult data; they grant no access and never cause implicit replay or a second workflow owner. The common model binding and exact App carrier also preserve the existing opaque ReasoningContinuityCarrier in ordered output, stream events and later assistant transcript, with the shared 64-KiB payload limit. SDK consumers preserve that item without provider-specific decoding or display; no new reasoning control or Runtime-owned cross-request store is introduced. + condition: Whenever the SDK exposes Local App function-tool or structured text consumption or optional host execution helpers. + failure: Fail closed on invalid or unrepresentable input, unsupported behavior, invalid output or incomplete streams; do not return pseudo model results, execute a tool without a complete model step, misassociate parallel results, silently repair structured output, claim unconfirmed cancellation, expose private authority, or replace formal App admission with an external-principal client. + relations: + - type: applies_to + target: definition.nimi.platform.core-protocol.app-operation-contract + - type: applies_to + target: definition.nimi.runtime.ai-provider.multimodal-provider-plane + - type: applies_to + target: definition.nimi.sdks.feature-clients.app-client-plane diff --git a/AGENTS.md b/AGENTS.md index 02cbca93b2..ffc6a419b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,70 +1,42 @@ # AGENTS.md -- Think before acting. Read before editing. Prefer bounded edits over rewrites. Be concise. Test the affected behavior. User instructions override this file. +## Scope and decisions -## Scope and Precedence +- This file applies repo-wide. Before working in a target subtree, read the applicable `AGENTS.md` chain from this Git root to that target, including intermediate ancestors. Reuse guidance already in context unless it changes; do not enumerate unrelated subtrees or read parent-directory guidance above this Git root. +- Explicit user instructions take precedence over this file. Reuse confirmed goals, topology, phase state, and authorization. Ask only about unresolved choices that materially change the result; continue independent authorized work. +- Complete the requested outcome within its authorized scope. Preserve unrelated work. Report concrete adjacent defects without silently expanding the task to repair them. -- This file applies repo-wide. The descendant `AGENTS.md` chain from this Git root to the target path may add or narrow rules. Do not discover or read parent-directory `AGENTS.md` files above this Git root. -- Treat explicit user direction, topology, phase state, and closed decisions as execution inputs. Change them only when the real product path or direct authority evidence contradicts them. -- Scope and proportionality rules constrain what is proposed and changed. They do not excuse an actual defect encountered during scoped work, and they do not authorize repo-wide discovery or audit. -- When user intent has a branch that would materially change the result, request the choice. Otherwise state the smallest necessary assumption and proceed. +## Product authority and ownership -## Product Authority and Hard Boundaries - -- Product authority lives under `.nimi/spec/**`; Git holds retired authority history. App-local spec slices must not create parallel truth. Host governance and configuration live under `.nimi/**` and `config/**`; `.local/**` is non-authoritative compatibility or evidence space. -- Use a formal `Spec Status` / `Authority Owner` / `Work Type` / `Parallel Truth` preflight only for redesign that changes product semantics or canonical ownership. Align `.nimi/spec/**` before such redesign. Alignment and bounded fixes follow existing authority without a preflight artifact. -- Before changing a shared Nimi UI primitive or contract, read `DESIGN.md` and `kit/DESIGN.md`; they are generated projections, not authority. App-local composition starts with its consumer and nearest guidance. -- Fail closed on contract violations. Do not add legacy shims, pseudo-success, app-level REST bypasses, provider or model hardcoding, file collisions, or forwarding shells outside `index.ts`. +- Product authority lives under `.nimi/spec/**`; Git holds retired authority history. App-local spec slices, implementation, generated projections, and local evidence must not create parallel product truth. Host governance and configuration live under `.nimi/**` and `config/**`; `.local/**` is non-authoritative. +- Before redesign that changes product semantics or canonical ownership, resolve `Spec Status`, `Authority Owner`, `Work Type`, and `Parallel Truth`, reusing confirmed decisions, and align canonical authority. Bounded fixes and alignment do not require a separate preflight artifact or renewed approval. +- Fail closed on contract violations. Do not add legacy shims, pseudo-success, app-level REST bypasses, provider/model hardcoding, file collisions, or forwarding shells outside `index.ts`. - Desktop and Web must not import `runtime/internal/**`. SDK must not cross Realm or Runtime private boundaries. Runtime must not import `sdks/**` or `apps/**`. -- The external AI host owns workflow state. Use guarded package scripts in `package.json` for host workflows. Invoke Nimi-coding operations from this repository root through the pinned project-local `pnpm exec nimicoding ...`, including where an installed managed block shows bare `nimicoding ...`. Never probe or rely on a global `nimicoding` in `PATH`. Nimi-coding must not own or mutate host task lifecycle. -- For Image2 or provider work under `nimi2d/**`, read `nimi2d/AGENTS.md`. - -## Execution - -- For product work, identify the current module's audience and task before choosing its presentation. Use the user's established direction and the consumer's context; advanced controls and developer documentation need their own level of precision. -- Authority supplies facts and constraints, not a mandatory copy or layout template. Within those boundaries, exercise product judgment over wording, grouping, hierarchy, and interaction. A misleading action, missing prerequisite, or inability to complete a supported task is a product defect even when compilation and contracts pass. -- For an authorized implementation or product-gate task, attempt the real affected build, launch, or journey first. -- Start debugging at the observed consumer. Inspect SDK or Runtime only when a trace, import boundary, or contract failure points upstream. -- Proceed as: real target attempt → first actual failure → smallest causal repair → rerun the same target → necessary evidence. -- `unknown`, unread, and `not_observed` mean unconfirmed; they are not evidence that behavior is invalid. A blocker must come from a real failing path or a located prerequisite that is actually missing. -- Later gates, historical mappings, global counts, and unrelated validation cannot block the current target unless they are direct prerequisites. -- Read historical plans, checkpoints, or evidence only when the current failure points there. Inspect only affected paths and preserve unrelated work. -- Do not delete or redesign working behavior based only on convention, effort, or sunk cost. Require a real product failure or direct authority evidence. - -## Findings and Proportionality - -- Report behavior that is actually wrong, including uncommon cases reachable through supported inputs, published interfaces, documented examples, or real project data. -- A reproduction is not mandatory for a review finding when the supported path and failure mechanism are concrete. Constructibility in principle is not enough. -- Unless the task, product authority, or deployed interface establishes an adversarial threat model, assume a cooperating operator on their own machine. Do not introduce security machinery for an unsupported threat model. -- Do not add hashes, checksums, or fingerprints as defensive evidence. Add them only when explicitly required by the task, authority, or security boundary, or when they replace a materially more expensive operation and their result changes subsequent behavior. -- Do not add speculative feature flags, migration frameworks, compatibility layers, wrappers, guards, or dual paths for cases the project does not produce. Current, supported transitions remain in scope when authority requires them. -- Do not pursue exotic encodings, symlink races, RTL text, sub-second timing races, or similar corner cases unless they are reachable through supported project use. -- Apply engineering judgment directly. Do not replace it with scoring tables, generic checklists, or repeated verification of an already settled result. -- Say plainly when inspected behavior is correct. Do not manufacture findings. +- The external AI host owns workflow state; Nimi-coding must not own or mutate host task lifecycle. Use the guarded scripts in the relevant `package.json` for host workflows. The managed block below defines project-local Nimi-coding execution. +- Product composition serves the current module's audience and task. Facts, behavior, ownership, safety, and release boundaries remain binding; wording, grouping, hierarchy, and interaction are product decisions. Advanced controls and developer documentation retain necessary precision. -## Retrieval Defaults +## Task-specific context -- Start with the changed consumer, its nearest `AGENTS.md`, direct dependencies, and exact authority IDs implicated by the task or first failure. -- When a task changes or reviews top-level Nimi/Home/App product or layer framing, public App lifecycle, or cross-domain canonical ownership, first read `rule.nimi.platform.core-protocol.p-arch-001a` with its declared outgoing context and then the affected exact domain owners. For public positioning or release-promise work, also read the exact implicated `P-GOV-026` rules beginning with `rule.nimi.platform.governance-release.p-gov-026-positioning` and `rule.nimi.platform.governance-release.p-gov-026-owner-boundary`. Local fixes remain consumer-first. -- For prompt or governance audits, search instruction filenames rather than repository content. Inspect only the instruction files and exact loader or checker configuration named by the task. Never search `.nimi/spec/**` or product implementation merely because an instruction mentions them. -- For planning-only tasks or requests to list bounded lookup targets, the list is the output, not an execution checklist. Read the applicable descendant `AGENTS.md` chain inside this Git worktree and any explicitly named files; these are the only product reads allowed. When naming a runnable or validation command, also read its direct package manifest. Targeted discovery of that chain is allowed; do not enumerate other product files, inspect Git state, or perform the proposed lookups. -- Skip `_external/**`, `.iterate/**`, `.cache/**`, `archive/**`, `docs/**`, generated code, lockfiles, and large assets unless directly required. +- Start at the requested target or observed consumer, its direct dependencies, and its nearest guidance. Inspect upstream owners when a concrete task question, dependency, trace, or contract requires them; do not require a runtime failure before investigating a directly implicated owner. +- For shared UI primitive or contract changes, read `DESIGN.md` and `kit/DESIGN.md` as generated projections. App-local composition starts with its consumer and nearest guidance. +- For top-level Nimi/Home/App framing, public App lifecycle, or cross-domain canonical ownership, read `rule.nimi.platform.core-protocol.p-arch-001a` with its declared outgoing context and the affected exact domain owners. Public positioning or release promises also require the implicated `P-GOV-026` rules beginning with `rule.nimi.platform.governance-release.p-gov-026-positioning` and `rule.nimi.platform.governance-release.p-gov-026-owner-boundary`. +- For prompt or governance audits, search instruction filenames and inspect the relevant instructions and their direct loaders/checkers. Do not inspect `.nimi/spec/**` or product implementation merely because an instruction mentions them. +- For canonical authority edits, reviews, or authority-command failures, use `.agents/skills/nimi-authority-work/SKILL.md`; ordinary implementation that follows settled contracts does not require this workflow. +- When the requested output is only a list of lookup targets, provide the list without performing the lookups or inspecting Git state. For an actual implementation plan, read the bounded inputs needed to resolve its decisions; do not execute the planned changes. Read the direct package manifest before naming a runnable command. +- Skip `_external/**`, `.iterate/**`, `.cache/**`, `archive/**`, `docs/**`, generated code, lockfiles, large assets, and historical plans/evidence unless directly relevant to the task. -## Verification +## Verification and findings -- Run a check only when it can detect a specific plausible failure and its result would change the next action. -- Run the nearest affected test, build, or real journey first. After a repair, rerun the same failing target before adding broader validation. -- Add broader guards only for changed cross-cutting contracts. Stop when evidence proportionate to the change is sufficient. -- Do not create extra environments, harnesses, Gates, checkpoints, evidence systems, receipts, ledgers, manifests, schemas, or repeated reviews solely to automate or prove acceptance. -- Automation success does not replace product acceptance. Product paths not actually run must be described as `NOT-VERIFIED`, not treated as blockers. -- For authority edits, use the managed commands below. -- For shared projection changes, run `pnpm check:nimi-design-artifacts`; do not run it for unrelated UI composition. +- For a reported failure, attempt the affected supported path when feasible, repair its cause, and rerun that path. For new work, verify the requested behavior without requiring a pre-existing failure. When the task includes launch or product interaction, run the real supported journey and address failures within scope. +- Select checks for a specific plausible failure or an applicable module requirement. Reuse passing results until a new change, failure, or concrete unresolved risk invalidates them. Complete required module checks; broader validation follows changed cross-cutting contracts. +- Supported uncommon cases remain in scope. Match threat assumptions and evidence strength to the actual task and interfaces; do not add speculative compatibility paths, corner-case machinery, or redundant evidence systems. +- Report actual incorrect behavior; a review finding may rely on a concrete supported path and failure mechanism without a reproduction. Say when inspected behavior is correct. +- `unknown`, unread, and `not_observed` mean unconfirmed. Missing prerequisites block only dependent work. Mark relevant product paths not actually run as `NOT-VERIFIED`; automation success alone does not establish product acceptance. +- For shared design projection changes, run `pnpm check:nimi-design-artifacts` unless an already-required check includes it. Do not run it for unrelated app composition. Authority edits follow the managed instructions below. -## Nimi App CDP +## Nimi App acceptance -- For real Nimi App acceptance or renderer debugging, use the guarded package script with Desktop-supervised Electron. Generic `nimi-app dev` selects an ephemeral loopback CDP port and prints it; use `pnpm --filter dev -- --cdp-port ` when the acceptance tool requires a stable port. Attach to that App's exact target; do not open its Vite renderer directly or attach to Desktop or another App. Native and owner UI remain outside CDP. -- Stay code-first: read the affected consumer and direct contract, use CDP only to reproduce or observe the first real failure, repair the smallest causal mechanism, then rerun the same journey. CDP success does not replace code review or affected tests. -- Keep CDP loopback-only, development-only, and ephemeral. The canonical root `pnpm dev:desktop`, `dev:zhiyu`, `dev:lab`, and `dev:avatar` launchers own deterministic default ports; generic `nimi-app dev` owns automatic port selection and may read the exact `NIMI_APP_DEV_CDP_PORT` override from the project `.env`. Use `--cdp-port ` to override either path and `--no-cdp` to disable it. Do not add other default CDP configuration, automation harnesses, Playwright projects, helper endpoints, recordings, fixtures, baselines, evidence systems, ledgers, or manifests. +- For real Nimi App acceptance or renderer debugging, read `.agents/skills/nimi-app-acceptance/SKILL.md`. Use Desktop-supervised Electron and that App's exact loopback development CDP target; direct Vite or another App's renderer is not acceptance. Native and owner UI remain outside CDP. # Nimi Coding Managed Block diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 06da286f83..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,31 +0,0 @@ -# CLAUDE.md - -> `AGENTS.md` files are authoritative. This file is only a navigation shim. - -## Navigation - -- A `PreToolUse` hook auto-injects the nearest module `AGENTS.md` before every Read/Edit/Write. -- For Grep, Glob, Bash, or planning, read the repository-root `AGENTS.md` and the nearest descendant `AGENTS.md` for the target path before acting. -- Apply root guidance first; the nearest descendant narrows its subtree. -- Do not duplicate project conventions here. If this file diverges from an `AGENTS.md`, follow the `AGENTS.md`. - - -# Nimi Coding Managed Block - -- From the repository root, invoke the pinned project-local CLI as `pnpm exec nimicoding`; do not probe or rely on a global `nimicoding` binary in `PATH`. -- Product authority lives under `.nimi/spec/**`. -- Choose authority and code queries when their declared scope can resolve an uncertainty that affects the current task; reuse sufficient current evidence. Query scope is not the limit of host reasoning or authorized work, and hypotheses are not product authority. -- For canonical authority authoring, read only `.nimi/methodology/authority-authoring.yaml`, the affected authority files or bounded task context, and CLI diagnostics. -- Use `pnpm exec nimicoding authority context --max-units --max-bytes --json` only for the complete declared outgoing interpretation closure; it is not complete task context, and failure never permits guessed or partial context. -- Use `pnpm exec nimicoding authority diff` and `pnpm exec nimicoding authority impact` with explicit `--max-bytes`; impact reports declared review obligations and does not prove implementation, consumers, or tests are synchronized. -- Use `pnpm exec nimicoding authority change-candidates` only with explicit channels and budgets; its complete union is recall input, never conflict, retirement, absence, authority, or conformance judgment. -- When explicit authority links are needed, use `pnpm exec nimicoding code authority --repo --authority --max-files --max-bytes ` to locate annotated code, and use `--source ` for code-to-authority lookup. Results cover only explicit markers and authority lifecycle; they do not prove implementation conformance or evaluate unannotated code. -- For a new or changed authority-governed feature, add the reserved standalone physical line `// @nimi-authority: ` in TypeScript/TSX, Go, or Rust, and `# @nimi-authority: ` in Python. The scanner does not prove language comment context, so use this reserved form only for intentional links at a few key semantic owners. -- Use `// @nimi-deprecated: `, or `# @nimi-deprecated: ` in Python, only after direct authority evidence or a real product failure confirms obsolete semantics; find it with `pnpm exec nimicoding code authority --repo --audit --max-files --max-bytes ` and remove it with the hard cut. -- When a selected TypeScript or TSX consumer still has a static-dependency question, use `pnpm exec nimicoding code context --repo --symbol --tsconfig --max-bytes ` for bounded root-direct static dependencies; it is not inbound impact, runtime dispatch, or complete task context. -- Use `pnpm exec nimicoding sync --check` to diagnose drift in package-owned managed projections, `pnpm exec nimicoding sync --apply` to restore them, and `pnpm exec nimicoding doctor` to diagnose package/managed compatibility. These commands do not validate product authority, implementation conformance, or task readiness. -- Under `.nimi/spec/**`, author only closed multi-unit `*.authority.yaml` containers or single-unit `*.authority.md`; historical document formats are unsupported and never inferred. -- Run `pnpm exec nimicoding authority fmt` on each changed file, then `pnpm exec nimicoding authority check` on the complete authority input set. -- A failed project-local `pnpm exec nimicoding ...` invocation supplies no usable result. Pause decisions that require refused, missing, or incomplete results; continue independent authorized work. Never substitute guessed, corpus-wide, or fallback context, or treat diagnostics or partial output as complete context; choose repair values only from product/task authority. -- Keep derived and local verification output under `.nimi/local/**`; it is never product authority. - diff --git a/app-tools/AGENTS.md b/app-tools/AGENTS.md index 951dc9bf76..0ce35172fb 100644 --- a/app-tools/AGENTS.md +++ b/app-tools/AGENTS.md @@ -24,5 +24,5 @@ - Read admission or platform authority only when the requested change alters those semantics. ## Verification Commands -- Run the focused Node test while iterating, then `pnpm --filter @nimiplatform/app-tools test`. +- Run the focused Node test while iterating. Run `pnpm --filter @nimiplatform/app-tools test` for generator, capability-closure, lifecycle, package, or shared template changes; prose-only guidance edits do not require the full suite. - Use `pnpm run sync:app-source -- --apply` only when explicitly materializing the derived admitted-slice snapshot for packaging inspection. diff --git a/app-tools/lib/app-doctor-update.mjs b/app-tools/lib/app-doctor-update.mjs index 1fea2f1329..35f8c903a7 100644 --- a/app-tools/lib/app-doctor-update.mjs +++ b/app-tools/lib/app-doctor-update.mjs @@ -18,6 +18,7 @@ const SCAN_EXCLUDED_DIRS = new Set([ '.git', '.turbo', '.vite', + '.next', 'build', 'coverage', 'dist', diff --git a/app-tools/lib/app-scaffold-profiles.mjs b/app-tools/lib/app-scaffold-profiles.mjs index 9be4c14b2f..d89ab5fd86 100644 --- a/app-tools/lib/app-scaffold-profiles.mjs +++ b/app-tools/lib/app-scaffold-profiles.mjs @@ -204,8 +204,11 @@ function renderDefaultElectronProductionPackager(identity) { " await writeFile(productionManifestPath, `${JSON.stringify(productionManifest, null, 2)}\\n`);", " await rm(path.join(productionSourceRoot, 'pnpm-lock.yaml'));", '', - " const nativeDestination = path.join(productionSourceRoot, 'node_modules', ...NATIVE_BINDING_PACKAGE.split('/'));", - ' await rm(nativeDestination, { recursive: true, force: true });', + " const installedNativePackage = path.join(productionSourceRoot, 'node_modules', ...NATIVE_BINDING_PACKAGE.split('/'));", + ' const nativeDestination = MACOS_BUILD', + " ? path.join(stagingRoot, 'nimi-native', 'protected-local')", + ' : installedNativePackage;', + ' await rm(installedNativePackage, { recursive: true, force: true });', ' await mkdir(path.dirname(nativeDestination), { recursive: true });', ' await cp(nativePackageRoot, nativeDestination, { recursive: true, dereference: true, force: false });', ' await mkdir(packagerTempRoot, { recursive: true });', @@ -223,7 +226,8 @@ function renderDefaultElectronProductionPackager(identity) { ' out: outputRoot,', ' tmpdir: packagerTempRoot,', ' overwrite: false,', - ' asar: false,', + " asar: { unpack: '**/*.node' },", + " ...(MACOS_BUILD ? { extraResource: [path.join(stagingRoot, 'nimi-native')] } : {}),", ' prune: false,', ' quiet: true,', ' derefSymlinks: true,', @@ -234,7 +238,7 @@ function renderDefaultElectronProductionPackager(identity) { ' preEmbedProvisioningProfile: false, strictVerify: true,', " optionsForFile: () => ({ entitlements: [], hardenedRuntime: false, timestamp: 'none' }),", ' } } : {}),', - ' afterInitialize: [async ({ buildPath }) => {', + ' beforeAsar: [async ({ buildPath }) => {', " const packagedManifestPath = path.join(buildPath, 'package.json');", " const packagedManifest = JSON.parse(await readFile(packagedManifestPath, 'utf8'));", ' packagedManifest.version = APP_VERSION;', diff --git a/app-tools/package.json b/app-tools/package.json index 44392a9096..f79c81b542 100644 --- a/app-tools/package.json +++ b/app-tools/package.json @@ -1,6 +1,6 @@ { "name": "@nimiplatform/app-tools", - "version": "0.5.1", + "version": "0.5.2", "description": "Public app-authoring CLI for Nimi App projects.", "license": "Apache-2.0", "type": "module", @@ -18,11 +18,11 @@ "provenance": true }, "nimiScaffoldVersions": { - "sdkVersion": "^0.11.0", - "appToolsVersion": "^0.5.1", - "nimicodingVersion": "0.6.2", + "sdkVersion": "^0.12.0", + "appToolsVersion": "^0.5.2", + "nimicodingVersion": "0.6.3", "aiSdkVersion": "^6.0.85", - "kitVersion": "^0.7.0", + "kitVersion": "^0.8.0", "reactVersion": "^19.2.8", "reactDomVersion": "^19.2.8", "i18nextVersion": "^25.10.10", diff --git a/app-tools/templates/default-starter/AGENTS.md b/app-tools/templates/default-starter/AGENTS.md index 4a2d42fd68..7599bf1cf6 100644 --- a/app-tools/templates/default-starter/AGENTS.md +++ b/app-tools/templates/default-starter/AGENTS.md @@ -28,6 +28,7 @@ - Read platform authority only when ownership or semantics remain ambiguous; skip unrelated platform packages and generated files. ## Verification Commands -- After `pnpm install` and `pnpm run init`, run `pnpm run sync`, `pnpm run check`, the directly affected tests, and the affected build. +- Initial setup: after `pnpm install` and `pnpm run init`, run `pnpm run sync`, `pnpm run check`, the directly affected tests, and the affected build. +- In an initialized project, app-owned changes use the directly affected tests and build. Rerun sync/check when scaffold inputs, managed glue, or dependencies change, and before packaging; do not repeat initialization for ordinary product edits. - Run the official Desktop-supervised App journey when product interaction is in scope; shell or native checks alone do not replace it. - Mark every product path not actually run as `NOT-VERIFIED`. Help text, focused tests, and CDP visibility do not establish implementation or release acceptance. diff --git a/app-tools/test/app-scaffold.test.mjs b/app-tools/test/app-scaffold.test.mjs index b80df6677f..b02fecb7aa 100644 --- a/app-tools/test/app-scaffold.test.mjs +++ b/app-tools/test/app-scaffold.test.mjs @@ -353,7 +353,7 @@ test('Electron production maps exact App SemVer to bounded Windows resource meta assert.match(packagerSource, /const RESOURCE_VERSION = MACOS_BUILD \? APP_VERSION : resolveWindowsResourceVersion\(APP_VERSION\);/u); assert.match(packagerSource, /appVersion: RESOURCE_VERSION,/u); assert.match(packagerSource, /buildVersion: RESOURCE_VERSION,/u); - assert.match(packagerSource, /afterInitialize: \[async \(\{ buildPath \}\) => \{/u); + assert.match(packagerSource, /beforeAsar: \[async \(\{ buildPath \}\) => \{/u); assert.match(packagerSource, /packagedManifest\.version = APP_VERSION;/u); }); @@ -504,7 +504,9 @@ test('standalone scaffold creates a generic starter with rewritten identity', as assert.doesNotMatch(electronProductionPackager, /\.nimi['"], ['"]local['"], ['"]electron-packager-stage/); assert.match(electronProductionPackager, /platform: NATIVE_PLATFORM/); assert.match(electronProductionPackager, /arch: NATIVE_ARCH/); - assert.match(electronProductionPackager, /asar: false/); + assert.match(electronProductionPackager, /asar: \{ unpack: '\*\*\/\*\.node' \}/); + assert.match(electronProductionPackager, /path\.join\(stagingRoot, 'nimi-native', 'protected-local'\)/); + assert.match(electronProductionPackager, /extraResource: \[path\.join\(stagingRoot, 'nimi-native'\)\]/); assert.match(electronProductionPackager, /name: APP_EXECUTABLE_NAME/); assert.match(electronProductionPackager, /executableName: APP_EXECUTABLE_NAME/); assert.match(electronProductionPackager, /Kit does not declare the current-platform protected native binding as optional/); @@ -2567,6 +2569,7 @@ test('sync adopts and check audits an existing submitted App without creating a const vendorExample = 'fetch("/api/vendor-example");\nconst url = "https://vendor.example/v1/chat/completions";\n'; for (const referencePath of [ + '.next/server/chunks/previous-build.js', '.agents/skills/vendor/references/example.md', '.claude/skills/vendor/SKILL.md', '.agents/skills/vendor/scripts/tests/fixtures/example.tsx', diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index 991dcedc02..f5ced75292 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -11,8 +11,6 @@ - Desktop account authentication is a browser-only Product Control gate: the renderer requests a RuntimeAccountService attempt, Electron carries the owner-issued URL and loopback callback, and the renderer receives no credential or bearer material. - Do not add embedded Nimi account login, registration, password, OTP, two-factor, wallet, social-provider, email-verification, or login-provider management controls. Non-credential Profile projection and edit remain Desktop-owned composition; credential management opens the admitted Web account surface. - Do not hardcode or assemble a Web login URL. Runtime unavailable or an invalid callback fails closed without a direct browser-login fallback. -- Authority preflight is required only for a redesign that changes product semantics or canonical ownership. -- Alignment and bounded fixes follow existing authority; redesign requires prior `.nimi/spec/**` alignment. - Desktop chat/UI must project runtime authority, not invent a parallel executable truth in renderer-local state. ## Retrieval Defaults @@ -31,6 +29,6 @@ - Never embed a whole settings page inside another surface (or vice versa); deep-link via `settings.openSection(id)` / `setActiveTab` instead. Single-home every control exactly once. ## Verification Commands -- Renderer: `pnpm --filter @nimiplatform/desktop typecheck` and the directly affected test. +- Renderer TypeScript or behavior changes: `pnpm --filter @nimiplatform/desktop typecheck` and the directly affected test. - Product Control native changes: targeted `cargo test --manifest-path apps/desktop/product-control-core/Cargo.toml` and, when the Node binding changes, `cargo test --manifest-path apps/desktop/product-control-node/Cargo.toml`. - Run only the Desktop boundary gate that covers the changed import, chat, bridge, or authority surface. diff --git a/apps/install-gateway/AGENTS.md b/apps/install-gateway/AGENTS.md index aedc34a4c1..a93a79c834 100644 --- a/apps/install-gateway/AGENTS.md +++ b/apps/install-gateway/AGENTS.md @@ -15,5 +15,5 @@ - Skip `dist/assets/` (generated install script copy). ## Verification Commands -- `pnpm --filter @nimiplatform/install-gateway test` -- `pnpm --filter @nimiplatform/install-gateway build` +- Worker behavior changes: run the directly affected test; use `pnpm --filter @nimiplatform/install-gateway test` for shared release, cache, or checksum logic. +- Install-script source, build-script, or packaging changes and release preparation: run `pnpm --filter @nimiplatform/install-gateway build`. diff --git a/apps/lab/AGENTS.md b/apps/lab/AGENTS.md index 335eee1086..b51bb1f7e7 100644 --- a/apps/lab/AGENTS.md +++ b/apps/lab/AGENTS.md @@ -24,4 +24,6 @@ - Kit `Button` wraps children in one inline content span with pointer events disabled; composite rows that lay out structured children dissolve that wrapper with `> span { display: contents; }` (and restore `pointer-events: auto` where nested Tooltips need hover). ## Verification Commands -- Run the focused test, then `pnpm --filter @nimiplatform/lab test` and `pnpm --filter @nimiplatform/lab run validate`. +- For implementation changes, run the directly affected test and `pnpm --filter @nimiplatform/lab typecheck`; locale-key or bundle-registration changes include `test/i18n-parity.test.mjs`. +- Run `pnpm --filter @nimiplatform/lab test` for app-wide behavior or shared shell/SDK/Kit integration changes. +- Run `pnpm --filter @nimiplatform/lab run validate` when `nimi.app.yaml`, its App Access declaration, submitted-input role, or the validator changes; this checks manifest declarations, not product behavior. diff --git a/apps/lab/package.json b/apps/lab/package.json index 354f09bc99..5a23aa4643 100644 --- a/apps/lab/package.json +++ b/apps/lab/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@nimiplatform/app-tools": "workspace:*", - "@nimiplatform/nimi-coding": "0.6.2", + "@nimiplatform/nimi-coding": "0.6.3", "@tailwindcss/vite": "^4.3.3", "@types/node": "^24.13.3", "@types/react": "19.2.18", diff --git a/apps/lab/src/studio-modules/studio-create/runtime.ts b/apps/lab/src/studio-modules/studio-create/runtime.ts index eb8145c4d0..57524dce47 100644 --- a/apps/lab/src/studio-modules/studio-create/runtime.ts +++ b/apps/lab/src/studio-modules/studio-create/runtime.ts @@ -225,6 +225,11 @@ async function* streamLocalAppTextEvents( actionHint: event.actionHint, }); } + if (event.type === 'tool-call' || event.finishReason === 'tool-calls') { + throw Object.assign(new Error('The text-only Studio request received undeclared tool output.'), { + reasonCode: 'SDK_AI_RUNTIME_OUTPUT_INVALID', + }); + } if (textItemOpened) { yield localTextCompletionDeltaEvent(event); } diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index a324ce722d..71d897ccb8 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -20,6 +20,7 @@ - Keep complete public claims and required policy disclosures accurate and reachable. Facts, owner boundaries, and safety obligations remain binding; equal-meaning wording, grouping, and visual hierarchy are Web-owned presentation decisions, not reasons to copy every internal term into the hero. ## Verification Commands -- `pnpm --filter @nimiplatform/web typecheck` -- `pnpm --filter @nimiplatform/web test` -- `pnpm --filter @nimiplatform/web build` +- TypeScript changes: run the directly affected tests and `pnpm --filter @nimiplatform/web typecheck`. +- Shared routing, account adapters, or app-wide behavior changes: run `pnpm --filter @nimiplatform/web test`. +- Bundling, generated landing data, assets, dependencies, or release preparation: run `pnpm --filter @nimiplatform/web build`; it already includes typecheck. +- When the task includes visual or interaction acceptance, inspect the affected Web route; static checks alone do not establish that result. diff --git a/kit/AGENTS.md b/kit/AGENTS.md index b46a1fe363..7b405eaaa0 100644 --- a/kit/AGENTS.md +++ b/kit/AGENTS.md @@ -4,7 +4,7 @@ - Active modules plus admitted implementation-pending native boundary: `kit/ui`, `kit/auth`, `kit/core`, `kit/telemetry`, `kit/shell/protected-local`, `kit/shell/tauri`, `kit/shell/renderer`, `kit/shell/electron`, and `kit/features`. ## Hard Boundaries - Kit UI is a reusable projection of `.nimi/spec/platform/ui-design-system.authority.yaml`; do not create app-local design truth in `kit/auth` or `kit/features`. -- Before adding UI or interaction logic, inspect `kit/README.md`, the target module README, and the nearest owner contract. +- Read `kit/README.md` and the target module README when choosing a public entrypoint or reusable owner; use the direct owner contract for the behavior being changed. - `kit/core` must stay pure logic: no React, CSS, app code, or presentation imports. - `kit/telemetry` must stay renderer-safe: no Node.js, Electron, or Tauri bridge imports. - `kit/shell/tauri` is shared Rust host glue; do not import JS/TS runtime code or app-local Rust. @@ -16,10 +16,11 @@ - SDK typed services may only bind from explicit `runtime` or `realm` feature surfaces; runtime integrations must not bind realm clients, realm integrations must not bind runtime clients, and apps consume toolkit functionality through `@nimiplatform/kit/*` once it exists. - `kit/auth` keeps Web Account Auth and Desktop Browser Auth Gate as separate public contracts: the Web adapter may expose Realm credential interactions through the Realm-owned browser session, while the Desktop gate accepts only the OAuth code bridge and Runtime account browser broker and must not require credential methods or token persistence. ## Retrieval Defaults -- Start in `kit/ui`, `kit/auth`, `kit/core`, `kit/telemetry`, `kit/shell/renderer`, `kit/shell/electron`, `kit/features`, `.nimi/spec/platform/ui-design-system.authority.yaml`, and `scripts/check-nimi-kit.mjs`; skip generated output except token/theme drift and generated platform docs. +- Start in the affected Kit module and its direct consumer or contract. Read canonical authority when semantics or ownership are unresolved; inspect the relevant checker when its failure or the changed projection requires it. Skip unrelated modules and generated output except when checking drift. ## Verification Commands -- `pnpm --filter @nimiplatform/kit build && pnpm --filter @nimiplatform/kit test`; `pnpm check:nimi-kit`. -- For shared design projection changes, also run `pnpm check:nimi-design-artifacts`. +- For implementation changes, run the directly affected test and `pnpm --filter @nimiplatform/kit build`; `kit/ui/AGENTS.md` adds the checks for UI generator inputs. +- Run `pnpm --filter @nimiplatform/kit test` for shared behavior changes spanning multiple components. Run `pnpm check:nimi-kit` for public exports, package/import boundaries, shared projections, or Kit-wide changes. +- `pnpm check:nimi-kit` already includes `pnpm check:nimi-design-artifacts`; do not run the included check again while its result remains valid. ## Semver Discipline - Public exports are governed by `kit/package.json`; classify every public export change before merge. - Patch: compatible fixes only. Minor: new export, widening, or 0.x breaking change. Major: explicit 1.0.0 or post-1.0 breaking change. diff --git a/kit/CHANGELOG.md b/kit/CHANGELOG.md index 6181f2eb16..f0005ff423 100644 --- a/kit/CHANGELOG.md +++ b/kit/CHANGELOG.md @@ -9,6 +9,27 @@ Discipline. ## [Unreleased] +### Local App text tools (0.8.0) + +- Requires SDK 0.12.0, the matching 0.8.0 native binding, and a Runtime that + admits the extended Local App text contract. Function tools, ordered tool + results and structured response formats use the existing text stream and + scenario execute operations. App-owned Node work can use `bridge.services` + on the same protected Host and cancel on `onSessionInvalidated`. +- Migration: handle the `tool-call` stream event and `tool-calls` finish reason; + text deltas now include `itemIndex`. Scenario execute adds `text-generate`. + Update exhaustive event/output matches. Rust shell crates advance to 0.4.0; + rich turns use `LocalAppTextMessage` and the extended text request fields. + The narrow text-candidate operation keeps its existing input contract. +- These APIs represent one model step. The App owns its tool handlers and + subsequent steps; the model binding does not execute callbacks or resume + interrupted work. +- The same unreleased minor adds `reasoning-continuity` stream/output items. + Preserve their ordered opaque carrier in later assistant turns without + displaying it. The common SDK model uses `Uint8Array`; the native JSON + carrier uses byte arrays, bounded to 64 KiB per payload. Raw reasoning and + reasoning controls are not exposed by Local App consumption. + ### Added - The protected music runner accepts an optional `durationSeconds` generation diff --git a/kit/core/src/sdk-contract.ts b/kit/core/src/sdk-contract.ts index bfd79d280c..ff3227fd5e 100644 --- a/kit/core/src/sdk-contract.ts +++ b/kit/core/src/sdk-contract.ts @@ -84,7 +84,17 @@ export type { NimiLocalAppVisionLocateResult, NimiLocalAppVisionLocation, NimiLocalAppTextTurnEvent, + NimiLocalAppTextTurnInput, + NimiLocalAppTextOutputItem, } from '@nimiplatform/sdk'; +export { validateNimiLocalAppTextInput, validateNimiLocalAppTextOutputItems } from '@nimiplatform/sdk/app'; +export { + createNimiLocalAppAIConsumptionClient, + createNimiLocalAppAIConfigClient, + createNimiAppRuntimeStorageClient, + createNimiLocalAppAssetsClient, +} from '@nimiplatform/sdk/app'; +export type { NimiLocalAppAIConsumptionClient, NimiLocalAppAssetBody } from '@nimiplatform/sdk/app'; export { createNimiCloudAIConfigCapabilityIntent, createNimiLocalAIConfigCapabilityIntent, diff --git a/kit/package.json b/kit/package.json index 848c665ee6..84a8131354 100644 --- a/kit/package.json +++ b/kit/package.json @@ -1,6 +1,6 @@ { "name": "@nimiplatform/kit", - "version": "0.7.0", + "version": "0.8.0", "description": "The product-grade cross-app toolkit for Nimi apps.", "license": "MIT", "private": false, @@ -358,7 +358,7 @@ }, "dependencies": { "@grpc/grpc-js": "^1.14.4", - "@nimiplatform/sdk": "workspace:^0.11.0", + "@nimiplatform/sdk": "workspace:^0.12.0", "@radix-ui/react-avatar": "^1.2.6", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-popover": "^1.1.23", diff --git a/kit/shell/electron/README.md b/kit/shell/electron/README.md index bdb209b810..b38829b221 100644 --- a/kit/shell/electron/README.md +++ b/kit/shell/electron/README.md @@ -12,16 +12,17 @@ Runtime access through the explicit `electron-ipc` transport. Desktop-supervised local apps register the fixed catalogued Local App host from their Electron main process. The admitted surface includes Runtime-selected -foreground text candidate generation and the bounded canonical Agent -configuration family: +text generation with function tools and structured output, managed storage, +and the bounded canonical Agent configuration family: ```ts import { registerNimiElectronAppBridge } from '@nimiplatform/kit/shell/electron/main'; -registerNimiElectronAppBridge({ +const bridge = registerNimiElectronAppBridge({ appId: 'nimi.example.local-app', allowedRendererUrls: [rendererUrl], ipcMain, + assetMediaPlatform: { protocol, webRequest: session.defaultSession.webRequest, webContents }, }); ``` @@ -39,8 +40,18 @@ reopen, or unregister the renderer bridge. Protected calls continue to return bounded typed unavailable posture while Kit performs bounded same-Host session rebind. App code receives no session material or authority selector. -This entrypoint deliberately has no Runtime endpoint, ordinary gRPC factory, -native-host injection, capability-set selection, or command-handler input. +App-owned Node modules use `bridge.services.ai`, `bridge.services.aiConfig` +and `bridge.services.storage`, which are SDK feature clients over this same +Host. Register fixed business commands through `appCommandHandlers` and use +`onSessionInvalidated` to abort application tasks and clear their account-scoped +memory. These handlers never occupy the reserved `nimi.shell.*` namespace. +Business requests carry their own cancellation signal into the SDK model +binding and check it before committing asynchronous work. Renderer disconnect +does not itself replay or cancel a business workflow; the App owns that policy. + +This entrypoint has no Runtime endpoint, ordinary gRPC factory, native-host +injection or capability-set selection. Services become unavailable when the +bridge is unregistered, and old resource handles cannot cross a session rebind. The public Windows native package targets the existing D2 Runtime. A Desktop-launched installed App loads that binding from its own package; diff --git a/kit/shell/electron/src/main/app-bridge.ts b/kit/shell/electron/src/main/app-bridge.ts index 8fb5a5ab5e..43fe40a41a 100644 --- a/kit/shell/electron/src/main/app-bridge.ts +++ b/kit/shell/electron/src/main/app-bridge.ts @@ -1,5 +1,6 @@ import { NIMI_LOCAL_APP_STANDARD_SHELL_CAPABILITY_SET_ID } from '@nimiplatform/kit/shell/capabilities'; import { registerNimiElectronRuntimeBridge } from './host.js'; +import { createAppBusinessServices, type NimiElectronAppBusinessServices } from './app-business-services.js'; import { requestElectronAgentCenterResourcePackPlacement } from './agent-center-resource-pack-placement.js'; import { createNimiElectronLocalAppHost, @@ -20,7 +21,7 @@ import { const LOCAL_APP_PROTECTED_CARRIER_SENTINEL = 'local-app-protected-carrier-only'; const REQUIRED_INPUT_KEYS = ['allowedRendererUrls', 'appId', 'assetMediaPlatform', 'ipcMain'] as const; -const OPTIONAL_INPUT_KEYS = ['agentCenterOpenFileDialog', 'appCommandHandlers'] as const; +const OPTIONAL_INPUT_KEYS = ['agentCenterOpenFileDialog', 'appCommandHandlers', 'onSessionInvalidated'] as const; const RESERVED_COMMAND_PREFIX = 'nimi.shell.'; let sourceLocalDevelopmentParentMonitor: NodeJS.Timeout | undefined; @@ -38,6 +39,8 @@ export type RegisterNimiElectronAppBridgeInput = { * cannot occupy the reserved `nimi.shell.*` namespace. */ readonly appCommandHandlers?: Readonly>; + /** Cancel App-owned work and discard account-scoped memory on this Host. */ + readonly onSessionInvalidated?: () => void; }; export type NimiElectronAgentCenterOpenFileDialog = NonNullable< @@ -46,6 +49,8 @@ export type NimiElectronAgentCenterOpenFileDialog = NonNullable< export type RegisteredNimiElectronAppBridge = RegisteredNimiElectronRuntimeBridge & Readonly<{ localAppHost: Pick; + /** Main-process SDK clients sharing this bridge's protected session. */ + services: NimiElectronAppBusinessServices; }>; /** @@ -72,7 +77,14 @@ export function registerNimiElectronAppBridge( ); } let localAppAssetMediaHost: ReturnType | undefined; - const localAppHost = createNimiElectronLocalAppHost(() => localAppAssetMediaHost?.invalidateAll()); + let business: ReturnType | undefined; + const invalidate = () => { + localAppAssetMediaHost?.invalidateAll(); + business?.invalidate(); + input.onSessionInvalidated?.(); + }; + const localAppHost = createNimiElectronLocalAppHost(invalidate); + business = createAppBusinessServices(localAppHost); localAppAssetMediaHost = createNimiElectronLocalAppAssetMediaHost({ localAppHost, platform: input.assetMediaPlatform, @@ -112,12 +124,12 @@ export function registerNimiElectronAppBridge( if (closed) return; closed = true; maintenance?.close(); + business.close(); + input.onSessionInvalidated?.(); localAppAssetMediaHost.close(); registered.unregister(); }; - maintenance = startNimiElectronLocalAppHostMaintenance(localAppHost, undefined, () => { - localAppAssetMediaHost.invalidateAll(); - }); + maintenance = startNimiElectronLocalAppHostMaintenance(localAppHost, undefined, invalidate); void maintenance.ready.catch(() => undefined); const placementLocalAppHost: RegisteredNimiElectronAppBridge['localAppHost'] = Object.freeze({ agentReferenceList: () => localAppHost.agentReferenceList(), @@ -126,6 +138,7 @@ export function registerNimiElectronAppBridge( return { invokeChannel: registered.invokeChannel, localAppHost: placementLocalAppHost, + services: business.services, unregister: closeBridge, }; } @@ -197,6 +210,13 @@ function assertExactAppBridgeInput(input: RegisterNimiElectronAppBridgeInput): v 'provide_host_native_agent_center_picker', ); } + if (input.onSessionInvalidated !== undefined && typeof input.onSessionInvalidated !== 'function') { + throw appBridgeInputError( + 'Electron App session invalidation callback must be a Host function', + 'electron-local-app-invalidation-callback-invalid', + 'provide_host_session_invalidation_callback', + ); + } } function validateAppCommandHandlers( diff --git a/kit/shell/electron/src/main/app-business-services.ts b/kit/shell/electron/src/main/app-business-services.ts new file mode 100644 index 0000000000..46ef10deb7 --- /dev/null +++ b/kit/shell/electron/src/main/app-business-services.ts @@ -0,0 +1,201 @@ +import { + createNimiLocalAppAIConsumptionClient, + createNimiLocalAppAIConfigClient, + createNimiAppRuntimeStorageClient, + createNimiLocalAppAssetsClient, + type NimiLocalAppAIConsumptionClient, + type NimiLocalAppAssetBody, + type NimiLocalAppClient, +} from '@nimiplatform/kit/core/sdk-contract'; +import { + NimiElectronLocalAppHostError, + type NimiElectronLocalAppHost, + type NimiElectronLocalAppRecord, +} from './local-app-host.js'; + +/** SDK clients for App-owned Node work running inside the verified App Host. */ +export type NimiElectronAppBusinessServices = Pick & { + readonly ai: NimiLocalAppAIConsumptionClient; +}; + +// @nimi-authority: rule.nimi.platform.app-ecosystem.p-scaf-016b +// This adapter reuses the bridge's existing protected Host; it creates no +// transport, session, endpoint, identity, executor or durable workflow state. +export function createAppBusinessServices(host: NimiElectronLocalAppHost) { + const resources = new Set<() => Promise>(); + let generation = 0; + let closed = false; + const check = (expected = generation) => { + if (closed || expected !== generation) throw new NimiElectronLocalAppHostError('session-invalid', false); + }; + const request = async (method: keyof NimiElectronLocalAppHost, payload: unknown = {}) => { + const expected = generation; + check(expected); + const result = await (host[method] as (value: NimiElectronLocalAppRecord) => Promise) + .call(host, payload as NimiElectronLocalAppRecord); + check(expected); + return result; + }; + const stream = async ( + open: 'textTurnSubscribe' | 'scenarioJobSubscribe', + next: 'textTurnStreamNext' | 'scenarioJobStreamNext', + close: 'textTurnStreamClose' | 'scenarioJobStreamClose', + payload: unknown, + ) => { + const expected = generation; + const { streamId } = await request(open, payload); + let stopped = false; + const cancel = async () => { + if (stopped) return; + stopped = true; + resources.delete(cancel); + await host[close]({ streamId }); + }; + resources.add(cancel); + return { + events: { + async *[Symbol.asyncIterator]() { + try { + while (!stopped) { + check(expected); + const result = await request(next, { streamId }); + check(expected); + if (stopped || result.completed === true) return; + yield result.event; + } + } finally { + await cancel(); + } + }, + }, + cancel, + }; + }; + const ai = createNimiLocalAppAIConsumptionClient({ + text: { streamTurn: (input) => stream('textTurnSubscribe', 'textTurnStreamNext', 'textTurnStreamClose', input) }, + scenario: { execute: (spec) => request('scenarioExecute', { spec }) }, + scenarioJobs: { + submit: (spec, options) => request('scenarioJobSubmit', { spec, timeoutMs: options?.timeoutMs ?? 0 }), + get: (jobId) => request('scenarioJobGet', { jobId }), + subscribe: (jobId) => stream('scenarioJobSubscribe', 'scenarioJobStreamNext', 'scenarioJobStreamClose', { jobId }), + cancel: (jobId, reason = '') => request('scenarioJobCancel', { jobId, reason }), + }, + artifacts: { + read: (artifactId) => request('artifactRead', { artifactId }), + upload: (input) => request('artifactUpload', input), + }, + voiceAssets: { list: (input) => request('voiceAssetsList', { pageSize: input?.pageSize ?? 0, pageToken: input?.pageToken ?? '' }) }, + }); + const assets = createNimiLocalAppAssetsClient({ + stat: (relativePath) => request('assetStat', { relativePath }), + list: (input) => request('assetList', { prefix: input.prefix, cursor: input.cursor ?? '', pageSize: input.pageSize ?? 0 }), + remove: (relativePath) => request('assetRemove', { relativePath }), + move: (input) => request('assetMove', { fromRelativePath: input.from, toRelativePath: input.to, overwrite: input.overwrite ?? false }), + reveal: (relativePath) => request('assetReveal', { relativePath }), + adoptArtifact: (input) => request('assetAdopt', { ...input, overwrite: input.overwrite ?? false }), + async write(input) { + const expected = generation; + const { streamId } = await request('assetWriteOpen', { + relativePath: input.relativePath, mediaType: input.mediaType ?? '', overwrite: input.overwrite ?? false, + }); + let committed = false; + let aborted = false; + const abort = async () => { + if (aborted || committed) return; + aborted = true; + await host.assetWriteAbort({ streamId }); + }; + resources.add(abort); + try { + for await (const chunk of assetChunks(input.body)) { + check(expected); + await request('assetWriteChunk', { streamId, bodyChunk: chunk }); + } + check(expected); + const result = await request('assetWriteCommit', { streamId }); + committed = true; + return result; + } finally { + resources.delete(abort); + if (!committed) await abort().catch(() => undefined); + } + }, + async read(input) { + const expected = generation; + const opened = await request('assetReadOpen', input); + let stopped = false; + const cancel = async () => { + if (stopped) return; + stopped = true; + resources.delete(cancel); + await host.assetReadClose({ streamId: opened.streamId }); + }; + resources.add(cancel); + return { + asset: opened.asset, + range: opened.range, + body: { + async *[Symbol.asyncIterator]() { + try { + while (!stopped) { + check(expected); + const result = await request('assetReadNext', { streamId: opened.streamId }); + check(expected); + if (stopped || result.completed === true) return; + yield result.bodyChunk; + } + } finally { + await cancel(); + } + }, + }, + }; + }, + }); + const services: NimiElectronAppBusinessServices = Object.freeze({ + ai, + aiConfig: createNimiLocalAppAIConfigClient({ + get: () => request('aiConfigGet'), + overwrite: (input) => request('aiConfigOverwrite', input), + listOptions: (input) => request('aiConfigLocalOptions', input), + }), + storage: Object.freeze({ + ...createNimiAppRuntimeStorageClient({ + readJson: (relativePath) => request('storageReadJson', { relativePath }), + writeJson: (relativePath, value) => request('storageWriteJson', { relativePath, value }), + removeJson: (relativePath) => request('storageRemoveJson', { relativePath }), + }), + assets, + }), + }); + const invalidate = () => { + generation++; + const pending = [...resources]; + resources.clear(); + void Promise.allSettled(pending.map((cancel) => cancel())); + }; + return { services, invalidate, close: () => { closed = true; invalidate(); } }; +} + +async function* assetChunks(body: NimiLocalAppAssetBody): AsyncIterable { + if (body instanceof Blob) { + const reader = body.stream().getReader(); + try { + while (true) { + const next = await reader.read(); + if (next.done) return; + yield* assetChunks(next.value); + } + } finally { + await reader.cancel(); + reader.releaseLock(); + } + } + const source = body instanceof Uint8Array ? [body] : body; + for await (const chunk of source) { + if (!(chunk instanceof Uint8Array)) throw new NimiElectronLocalAppHostError('invalid-input', false); + for (let offset = 0; offset < chunk.byteLength; offset += 1024 * 1024) { + yield chunk.subarray(offset, offset + 1024 * 1024); + } + } +} diff --git a/kit/shell/electron/src/main/index.ts b/kit/shell/electron/src/main/index.ts index b543c895e7..effb4cd164 100644 --- a/kit/shell/electron/src/main/index.ts +++ b/kit/shell/electron/src/main/index.ts @@ -8,6 +8,7 @@ export * from './agent-center.js'; export * from './agent-center-resource-pack-placement.js'; export * from './desktop-open.js'; export * from './avatar-host-handoff.js'; +export type { NimiElectronAppBusinessServices } from './app-business-services.js'; export { registerNimiElectronAppBridge, type NimiElectronAgentCenterOpenFileDialog, diff --git a/kit/shell/electron/src/main/local-app-commands.ts b/kit/shell/electron/src/main/local-app-commands.ts index e00b0b20b6..000be4255c 100644 --- a/kit/shell/electron/src/main/local-app-commands.ts +++ b/kit/shell/electron/src/main/local-app-commands.ts @@ -1,5 +1,6 @@ import { Buffer } from 'node:buffer'; import { NIMI_STANDARD_SHELL_COMMANDS } from '@nimiplatform/kit/shell/capabilities'; +import { validateNimiLocalAppTextInput } from '@nimiplatform/kit/core/sdk-contract'; import { NimiElectronLocalAppHostError, type NimiElectronLocalAppHost, @@ -890,23 +891,12 @@ function textTurnPayload( payload: Readonly>, command: string, ): NimiElectronLocalAppRecord { - const output = textInputBasePayload( - payload, - command, - ['messages', 'temperature', 'topP', 'maxTokens', 'topK', 'presencePenalty', 'frequencyPenalty', 'stop', 'seed'], - ); - if (payload.topK !== undefined) output.topK = boundedSafeInteger(payload.topK, 'topK', command, 0, 2_147_483_647); - if (payload.presencePenalty !== undefined) output.presencePenalty = boundedFiniteNumber(payload.presencePenalty, 'presencePenalty', command, -2, 2); - if (payload.frequencyPenalty !== undefined) output.frequencyPenalty = boundedFiniteNumber(payload.frequencyPenalty, 'frequencyPenalty', command, -2, 2); - if (payload.stop !== undefined) { - if (!Array.isArray(payload.stop) - || payload.stop.some((value) => typeof value !== 'string' || value.trim() === '')) { - throw invalidPayload(command, 'stop is invalid'); - } - output.stop = [...payload.stop]; + // @nimi-authority: rule.nimi.sdks.feature-clients.local-app-text-behaviors + try { + return validateNimiLocalAppTextInput(payload) as unknown as NimiElectronLocalAppRecord; + } catch { + throw invalidPayload(command, 'text input is invalid'); } - if (payload.seed !== undefined) output.seed = boundedSafeInteger(payload.seed, 'seed', command, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); - return output; } function textInputBasePayload( @@ -966,6 +956,11 @@ function validateScenarioSpec(value: unknown, command: string, execute: boolean) if (!isPlainRecord(value) || typeof value.type !== 'string') { throw invalidPayload(command, 'scenario spec is invalid'); } + if (value.type === 'text-generate' && execute) { + const { type: _type, ...input } = value; + textTurnPayload(input, command); + return; + } assertNoForbiddenAuthorityValue(value, command); const acceptsInlineAudio = !execute && (value.type === 'speech-transcribe' || value.type === 'voice-create'); diff --git a/kit/shell/electron/src/main/local-app-host.ts b/kit/shell/electron/src/main/local-app-host.ts index adea57b4b5..d002d2e13e 100644 --- a/kit/shell/electron/src/main/local-app-host.ts +++ b/kit/shell/electron/src/main/local-app-host.ts @@ -126,6 +126,12 @@ const ADMITTED_REASON_CODES: ReadonlySet = new Set([ 'ai-route-fallback-denied', 'ai-input-invalid', 'ai-output-invalid', + 'ai-text-behavior-unsupported', + 'ai-text-behavior-ambiguous', + 'ai-text-output-incomplete', + 'ai-tool-call-invalid', + 'ai-reasoning-continuity-invalid', + 'ai-execution-interrupted', 'ai-content-filter-blocked', 'ai-local-model-unavailable', 'ai-local-model-profile-missing', @@ -579,11 +585,12 @@ function withBoundedSessionRebind( if (!isSessionInvalidOutcome(first)) { return first; } + // Old App-owned work must stop even when the subsequent rebind fails. + onSessionChange(); const rebound = await renew(); if (!isReadySessionOutcome(rebound)) { return rebound.status === 'error' ? rebound : untrustedNativeOutcome(); } - onSessionChange(); if (!LOCAL_APP_BINDING_RETRY_SAFE_METHODS.has(property)) { return first; } @@ -643,11 +650,9 @@ function untrustedNativeOutcome(): NativeLocalAppOutcome { class ElectronLocalAppHost implements NimiElectronLocalAppHost { private readonly binding: NimiElectronProtectedLocalBinding; - private readonly onSessionChange: () => void; - private readonly textTurnStreams = new Map(); + private readonly textTurnStreams = new Map(); constructor(binding: NimiElectronProtectedLocalBinding, onSessionChange: () => void = () => undefined) { - this.onSessionChange = onSessionChange; this.binding = withBoundedSessionRebind(binding, onSessionChange); } @@ -656,9 +661,10 @@ class ElectronLocalAppHost implements NimiElectronLocalAppHost { } async renewTechnicalSession(): Promise { - const renewed = await invokeRecord(() => this.binding.localAppSessionRenew()); - this.onSessionChange(); - return renewed; + // @nimi-authority: rule.nimi.runtime.protected-session.r016 + // Runtime only renews a live session after revalidating the same context. + // A successful renewal preserves resources and App-owned work. + return invokeRecord(() => this.binding.localAppSessionRenew()); } aiConfigGet(): Promise { @@ -679,7 +685,7 @@ class ElectronLocalAppHost implements NimiElectronLocalAppHost { async textTurnSubscribe(input: NimiElectronLocalAppRecord): Promise { const opened = await invokeExactTextRecord(() => this.binding.localAppTextTurnSubscribe(input), ['streamId']); - this.textTurnStreams.set(String(opened.streamId), { bytes: 0, sequence: 0n }); + this.textTurnStreams.set(String(opened.streamId), { sequence: 0n }); return opened; } @@ -700,13 +706,9 @@ class ElectronLocalAppHost implements NimiElectronLocalAppHost { const sequence = BigInt(event.sequence); if (sequence !== state.sequence + 1n) throw untrustedRuntimeError(); state.sequence = sequence; - if (event.type === 'delta' && typeof event.text === 'string') { - state.bytes += Buffer.byteLength(event.text, 'utf8'); - if (state.bytes > 256 * 1024) { - this.textTurnStreams.delete(streamId); - throw untrustedRuntimeError(); - } - } + // @nimi-authority: rule.nimi.runtime.ai-provider.local-app-text-behaviors + // The native carrier enforces the total on the original protobuf. Parsed + // tool arguments cannot reproduce its byte size from their JSON spelling. return next; } @@ -1542,9 +1544,31 @@ async function invokeScenarioExecute( } const traceId = boundedExactText(value.traceId, 512, false); const output = value.output; + if (output.type === 'text-generate') { + if (!hasExactKeys(output, ['type', 'items', 'finishReason']) || !Array.isArray(output.items) + || output.items.length === 0 || !['stop', 'length', 'tool-calls', 'content-filter'].includes(String(output.finishReason))) throw untrustedRuntimeError(); + const ids = new Set(); + const items = output.items.map((item) => { + if (!isPlainRecord(item)) throw untrustedRuntimeError(); + if (item.type === 'text' && hasExactKeys(item, ['type', 'text'])) { + return Object.freeze({ type: 'text', text: boundedUtf8Content(item.text, 256 * 1024) }); + } + if (item.type === 'reasoning-continuity' && hasExactKeys(item, ['type', 'carrier'])) { + return Object.freeze({ type: 'reasoning-continuity', carrier: validateTextContinuity(item.carrier) }); + } + if (item.type !== 'tool-call' || !hasExactKeys(item, ['type', 'toolCall'])) throw untrustedRuntimeError(); + const toolCall = validateTextToolCall(item.toolCall); + if (ids.has(String(toolCall.id))) throw untrustedRuntimeError(); + ids.add(String(toolCall.id)); + return Object.freeze({ type: 'tool-call', toolCall }); + }); + if (!items.some((item) => item.type === 'text' || item.type === 'tool-call') || (output.finishReason === 'tool-calls' && ids.size === 0)) throw untrustedRuntimeError(); + return Object.freeze({ output: Object.freeze({ type: 'text-generate', items: Object.freeze(items), finishReason: String(output.finishReason) }), traceId }); + } if (output.type === 'text-embed') { - if (!hasExactKeys(output, ['type', 'vectors']) || !Array.isArray(output.vectors) + if (!hasExactKeys(output, ['type', 'vectors', 'spaceId']) || !Array.isArray(output.vectors) || output.vectors.length === 0 || output.vectors.length > 16) throw untrustedRuntimeError(); + const spaceId = boundedExactText(output.spaceId, 128, false); const vectors = output.vectors.map((vector) => { if (!Array.isArray(vector) || vector.length === 0 || vector.length > 8192 || vector.some((entry) => typeof entry !== 'number' || !Number.isFinite(entry))) { @@ -1552,7 +1576,7 @@ async function invokeScenarioExecute( } return Object.freeze([...vector]); }); - return Object.freeze({ output: Object.freeze({ type: 'text-embed', vectors: Object.freeze(vectors) }), traceId }); + return Object.freeze({ output: Object.freeze({ type: 'text-embed', vectors: Object.freeze(vectors), spaceId }), traceId }); } if (output.type === 'image-generate') { if (!hasExactKeys(output, ['type', 'artifacts']) || !Array.isArray(output.artifacts)) throw untrustedRuntimeError(); @@ -1848,6 +1872,13 @@ function validateScenarioJobEvent(value: unknown): NimiElectronLocalAppRecord { }) as NimiElectronLocalAppRecord; } +function validateTextContinuity(value: unknown): NimiElectronLocalAppRecord { + if (!isPlainRecord(value) || !hasExactKeys(value, ['kind', 'version', 'payload']) + || !Array.isArray(value.payload) || value.payload.length === 0 || value.payload.length > 64 * 1024 + || value.payload.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255)) throw untrustedRuntimeError(); + return Object.freeze({ kind: boundedExactText(value.kind, 128, false), version: boundedInteger(value.version, 1, 0xffff_ffff), payload: Object.freeze([...value.payload]) }); +} + function validateTextTurnEvent(value: unknown): NimiElectronLocalAppRecord { if (!isPlainRecord(value) || typeof value.type !== 'string' || typeof value.sequence !== 'string' || !/^[1-9][0-9]*$/u.test(value.sequence)) { @@ -1855,24 +1886,44 @@ function validateTextTurnEvent(value: unknown): NimiElectronLocalAppRecord { } const traceId = boundedExactText(value.traceId, 512, false); if (value.type === 'delta') { - if (!hasExactKeys(value, ['type', 'sequence', 'traceId', 'text'])) throw untrustedRuntimeError(); + if (!hasExactKeys(value, ['type', 'sequence', 'traceId', 'text', 'itemIndex'])) throw untrustedRuntimeError(); return Object.freeze({ type: 'delta', sequence: value.sequence, traceId, - text: boundedUtf8Content(value.text, 64 * 1024) }); + text: boundedUtf8Content(value.text, 64 * 1024), itemIndex: boundedInteger(value.itemIndex, 0, 4_294_967_295) }); + } + if (value.type === 'tool-call') { + if (!hasExactKeys(value, ['type', 'sequence', 'traceId', 'itemIndex', 'toolCall'])) throw untrustedRuntimeError(); + return Object.freeze({ type: 'tool-call', sequence: value.sequence, traceId, + itemIndex: boundedInteger(value.itemIndex, 0, 4_294_967_295), toolCall: validateTextToolCall(value.toolCall) }); + } + if (value.type === 'reasoning-continuity') { + if (!hasExactKeys(value, ['type', 'sequence', 'traceId', 'itemIndex', 'carrier'])) throw untrustedRuntimeError(); + return Object.freeze({ type: 'reasoning-continuity', sequence: value.sequence, traceId, + itemIndex: boundedInteger(value.itemIndex, 0, 4_294_967_295), carrier: validateTextContinuity(value.carrier) }); } if (value.type === 'completed') { if (!hasExactKeys(value, ['type', 'sequence', 'traceId', 'finishReason']) - || !['stop', 'length', 'content-filter'].includes(String(value.finishReason))) throw untrustedRuntimeError(); + || !['stop', 'length', 'tool-calls', 'content-filter'].includes(String(value.finishReason))) throw untrustedRuntimeError(); return Object.freeze({ type: 'completed', sequence: value.sequence, traceId, finishReason: String(value.finishReason) }); } if (value.type === 'failed') { - if (!hasExactKeys(value, ['type', 'sequence', 'traceId', 'reasonCode', 'actionHint'])) throw untrustedRuntimeError(); + if (!hasExactKeys(value, ['type', 'sequence', 'traceId', 'reasonCode', 'actionHint', ...(Object.hasOwn(value, 'interruption') ? ['interruption'] : [])])) throw untrustedRuntimeError(); + const interruption = value.interruption == null ? undefined : value.interruption; + if ((interruption !== undefined) !== (value.reasonCode === 'ai-execution-interrupted')) throw untrustedRuntimeError(); + if (interruption !== undefined && (!isPlainRecord(interruption) || !hasExactKeys(interruption, ['cause', 'resubmitDisposition']) + || interruption.cause !== 'runtime-restart' || interruption.resubmitDisposition !== 'caller-may-resubmit')) throw untrustedRuntimeError(); return Object.freeze({ type: 'failed', sequence: value.sequence, traceId, reasonCode: boundedExactText(value.reasonCode, 128, false), - actionHint: boundedExactText(value.actionHint, 512, true) }); + actionHint: boundedExactText(value.actionHint, 512, true), ...(interruption ? { interruption: interruption as NimiElectronLocalAppRecord } : {}) }); } throw untrustedRuntimeError(); } +function validateTextToolCall(value: unknown): NimiElectronLocalAppRecord { + if (!isPlainRecord(value) || !hasExactKeys(value, ['id', 'name', 'arguments']) || !isPlainRecord(value.arguments)) throw untrustedRuntimeError(); + validateJsonValue(value.arguments); + return Object.freeze({ id: boundedExactText(value.id, 128, false), name: boundedExactText(value.name, 128, false), arguments: value.arguments }); +} + function validateTimestamp(value: unknown): NimiElectronLocalAppRecord | null { if (value === null) return null; if (!isPlainRecord(value) || !hasExactKeys(value, ['seconds', 'nanos']) @@ -2506,7 +2557,7 @@ function optionalExactText(value: unknown): string | null { return exactText(value); } -function validateJsonValue(value: unknown, depth = 0, budget = { nodes: 0 }): void { +function validateJsonValue(value: unknown, depth = 0, budget = { nodes: 0 }): asserts value is NimiElectronLocalAppJson { budget.nodes += 1; if (depth > 32 || budget.nodes > 100_000) throw untrustedRuntimeError(); if (value === null || typeof value === 'string' || typeof value === 'boolean') return; diff --git a/kit/shell/electron/test/electron-app-business-services.test.ts b/kit/shell/electron/test/electron-app-business-services.test.ts new file mode 100644 index 0000000000..dd0705f83f --- /dev/null +++ b/kit/shell/electron/test/electron-app-business-services.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { createNimiLocalAppTextModel } from '@nimiplatform/sdk/ai'; +import { createAppBusinessServices } from '../src/main/app-business-services.js'; +import type { NimiElectronLocalAppHost } from '../src/main/local-app-host.js'; + +describe('App-owned Node services on the existing protected Host', () => { + it('maps SDK asset move paths to the native Host contract', async () => { + const requests: unknown[] = []; + const owner = createAppBusinessServices({ + assetMove: async (input: unknown) => { + requests.push(input); + return { relativePath: 'saved/result.json', mediaType: 'application/json', sizeBytes: 2, + sha256: `sha256:${'a'.repeat(64)}`, createdAt: '2026-09-12T00:00:00Z', updatedAt: '2026-09-12T00:00:00Z' }; + }, + } as unknown as NimiElectronLocalAppHost); + await owner.services.storage.assets.move({ from: 'draft/result.json', to: 'saved/result.json' }); + expect(requests).toEqual([{ fromRelativePath: 'draft/result.json', toRelativePath: 'saved/result.json', overwrite: false }]); + owner.close(); + }); + + it('uses the common model binding and preserves complete ordered tool output', async () => { + const requests: unknown[] = []; + let closed = 0; + const events = [ + { type: 'delta', sequence: '1', traceId: 'trace-1', itemIndex: 0, text: 'Searching.' }, + { type: 'tool-call', sequence: '2', traceId: 'trace-1', itemIndex: 1, toolCall: { id: 'call-1', name: 'search', arguments: { subject: 'trees' } } }, + { type: 'completed', sequence: '3', traceId: 'trace-1', finishReason: 'tool-calls' }, + ]; + const owner = createAppBusinessServices({ + textTurnSubscribe: async (input: unknown) => { requests.push(input); return { streamId: 's-1' }; }, + textTurnStreamNext: async () => events.length ? { completed: false, event: events.shift() } : { completed: true }, + textTurnStreamClose: async () => { closed++; return { closed: true }; }, + } as unknown as NimiElectronLocalAppHost); + const model = createNimiLocalAppTextModel(owner.services.ai); + const result = await model.generateText({ + messages: [{ role: 'user', content: [{ type: 'text', text: 'Find sources.' }] }], + tools: [{ name: 'search', inputSchema: { type: 'object', properties: { subject: { type: 'string' } } } }], + }); + expect(result.finishReason).toBe('tool-calls'); + expect(result.outputItems).toEqual([ + { type: 'text', text: 'Searching.' }, + { type: 'tool-call', toolCall: { id: 'call-1', name: 'search', arguments: { subject: 'trees' } } }, + ]); + expect(requests).toHaveLength(1); + expect(closed).toBe(1); + owner.close(); + }); + + it('cancels a model request while the protected stream is waiting', async () => { + let finishNext!: (value: { completed: true }) => void; + let entered!: () => void; + const waiting = new Promise((resolve) => { entered = resolve; }); + let closes = 0; + const owner = createAppBusinessServices({ + textTurnSubscribe: async () => ({ streamId: 's-1' }), + textTurnStreamNext: () => { entered(); return new Promise((resolve) => { finishNext = resolve; }); }, + textTurnStreamClose: async () => { closes++; finishNext({ completed: true }); return { closed: true }; }, + } as unknown as NimiElectronLocalAppHost); + const signal = new AbortController(); + const result = createNimiLocalAppTextModel(owner.services.ai).generateText({ + messages: [{ role: 'user', content: [{ type: 'text', text: 'Find sources.' }] }], signal: signal.signal, + }); + const rejected = expect(result).rejects.toThrow(); + await waiting; + signal.abort(); + await rejected; + expect(closes).toBe(1); + owner.close(); + }); + + it('stops a pending asset write at session invalidation before it can publish a commit', async () => { + let finishChunk!: () => void; + let entered!: () => void; + const waiting = new Promise((resolve) => { entered = resolve; }); + let commits = 0; + let aborts = 0; + const owner = createAppBusinessServices({ + assetWriteOpen: async () => ({ streamId: 'write-1' }), + assetWriteChunk: () => { entered(); return new Promise((resolve) => { finishChunk = () => resolve({ accepted: true }); }); }, + assetWriteCommit: async () => { commits++; throw new Error('old write must not commit'); }, + assetWriteAbort: async () => { aborts++; return { closed: true }; }, + } as unknown as NimiElectronLocalAppHost); + const write = owner.services.storage.assets.write({ relativePath: 'research/body.json', body: new Uint8Array([1, 2, 3]) }); + const rejected = expect(write).rejects.toMatchObject({ reasonCode: 'session-invalid' }); + await waiting; + owner.invalidate(); + finishChunk(); + await rejected; + expect(commits).toBe(0); + expect(aborts).toBe(1); + owner.close(); + await expect(owner.services.storage.readJson('config.json')).rejects.toMatchObject({ reasonCode: 'session-invalid' }); + }); +}); diff --git a/kit/shell/electron/test/electron-local-app-commands.test.ts b/kit/shell/electron/test/electron-local-app-commands.test.ts index a14333e4b2..88ce7b9fa6 100644 --- a/kit/shell/electron/test/electron-local-app-commands.test.ts +++ b/kit/shell/electron/test/electron-local-app-commands.test.ts @@ -13,6 +13,41 @@ import { dispatchElectronLocalAppCommand } from '../src/main/local-app-commands. import { FakeIpcMain, createInvokeEvent, invokeBridge } from './electron-shell-test-utils.js'; describe('Electron local-app standard-shell operations', () => { + it('carries ordered tool transcripts through both text commands without treating business JSON as authority', async () => { + const calls: unknown[] = []; + const host = localAppHost(calls); + const input = { + messages: [ + { role: 'user', text: 'Search the subject' }, + { role: 'assistant', text: '', turnItems: [ + { type: 'output', output: { type: 'tool-call', toolCall: { id: 'call-1', name: 'search', arguments: { subject: 'trees' } } } }, + { type: 'tool-result', toolResult: { toolCallId: 'call-1', toolName: 'search', result: { subject: 'trees', token: 'a business word' } } }, + ] }, + ], + tools: [{ type: 'function', name: 'search', inputSchema: { type: 'object', properties: { subject: { type: 'string' } } } }], + toolChoice: 'auto', + }; + await dispatchElectronLocalAppCommand({ + host, command: NIMI_STANDARD_SHELL_COMMANDS['local-app.textTurnStream'], + payload: input, sendEvent: () => undefined, + }); + await dispatchElectronLocalAppCommand({ + host, command: NIMI_STANDARD_SHELL_COMMANDS['local-app.scenarioExecute'], + payload: { spec: { type: 'text-generate', ...input } }, + }); + expect(calls).toEqual([ + ['textTurnSubscribe', input], + ['scenarioExecute', { spec: { type: 'text-generate', ...input } }], + ]); + for (const field of [{ provider: 'private' }, { tools: [{ type: 'provider', name: 'search' }] }]) { + await expect(dispatchElectronLocalAppCommand({ + host, command: NIMI_STANDARD_SHELL_COMMANDS['local-app.textTurnStream'], + payload: { ...input, ...field }, sendEvent: () => undefined, + })).rejects.toMatchObject({ reasonCode: 'invalid-payload' }); + } + expect(calls).toHaveLength(2); + }); + it('accepts iterator cleanup after the Job stream has naturally completed', async () => { let completed!: () => void; const ended = new Promise((resolve) => { completed = resolve; }); @@ -269,7 +304,7 @@ describe('Electron local-app standard-shell operations', () => { host, command, payload: { spec: { type: 'text-embed', inputs: ['hello'] } }, - })).resolves.toEqual({ output: { type: 'text-embed', vectors: [[0.1]] }, traceId: 'trace-1' }); + })).resolves.toEqual({ output: { type: 'text-embed', vectors: [[0.1]], spaceId: 'space-test-1' }, traceId: 'trace-1' }); expect(calls).toEqual([['scenarioExecute', { spec: { type: 'text-embed', inputs: ['hello'] } }]]); await expect(dispatchElectronLocalAppCommand({ @@ -584,7 +619,7 @@ describe('Electron local-app standard-shell operations', () => { await expect(invokeBridge(ipcMain, createInvokeEvent().event, { command: NIMI_STANDARD_SHELL_COMMANDS['local-app.scenarioExecute'], payload: { payload: { spec: { type: 'text-embed', inputs: ['hello'] } } }, - })).resolves.toEqual({ output: { type: 'text-embed', vectors: [[0.1]] }, traceId: 'trace-1' }); + })).resolves.toEqual({ output: { type: 'text-embed', vectors: [[0.1]], spaceId: 'space-test-1' }, traceId: 'trace-1' }); expect(calls).toEqual([['scenarioExecute', { spec: { type: 'text-embed', inputs: ['hello'] } }]]); }); @@ -1186,7 +1221,7 @@ function localAppHost(calls: unknown[]) { textTurnStreamClose: async () => ({ closed: true }), scenarioExecute: async (input: unknown) => { calls.push(['scenarioExecute', input]); - return { output: { type: 'text-embed', vectors: [[0.1]] }, traceId: 'trace-1' }; + return { output: { type: 'text-embed', vectors: [[0.1]], spaceId: 'space-test-1' }, traceId: 'trace-1' }; }, scenarioJobSubmit: async (input: unknown) => { calls.push(['scenarioJobSubmit', input]); diff --git a/kit/shell/electron/test/electron-local-app-host.test.ts b/kit/shell/electron/test/electron-local-app-host.test.ts index b0769336de..9cfbeab823 100644 --- a/kit/shell/electron/test/electron-local-app-host.test.ts +++ b/kit/shell/electron/test/electron-local-app-host.test.ts @@ -8,6 +8,46 @@ import { } from '../src/main/local-app-host.js'; describe('Electron protected local-app host', () => { + it('preserves the embedding space through the Host projection and rejects an absent identity', async () => { + const candidate = binding([]); + const host = createNimiElectronLocalAppHostForBinding(candidate); + await expect(host.scenarioExecute({ spec: { type: 'text-embed', inputs: ['document'] } })).resolves.toEqual({ + output: { type: 'text-embed', vectors: [[0.1, 0.2]], spaceId: 'space-test-1' }, traceId: 'trace-1', + }); + candidate.localAppScenarioExecute = async () => ({ status: 'ok', value: { + output: { type: 'text-embed', vectors: [[0.1, 0.2]] }, traceId: 'trace-1', + } }); + await expect(host.scenarioExecute({ spec: { type: 'text-embed', inputs: ['document'] } })).rejects.toMatchObject({ + reasonCode: 'runtime-service-untrusted', + }); + }); + it('preserves App-owned work on a successful routine renewal', async () => { + let invalidated = 0; + const host = createNimiElectronLocalAppHostForBinding(binding([]), () => { invalidated++; }); + await expect(host.renewTechnicalSession()).resolves.toEqual(statusProjection()); + expect(invalidated).toBe(0); + }); + + it('invalidates App-owned work before an unsuccessful technical rebind', async () => { + let invalidated = false; + const candidate = binding([]); + candidate.localAppStorageReadJson = async () => ({ status: 'error', reasonCode: 'account-changed', retryable: false }); + candidate.localAppSessionRenew = async () => { + expect(invalidated).toBe(true); + return { status: 'error', reasonCode: 'runtime-service-unavailable', retryable: true }; + }; + const host = createNimiElectronLocalAppHostForBinding(candidate, () => { invalidated = true; }); + await expect(host.storageReadJson({ relativePath: 'config.json' })).rejects.toMatchObject({ reasonCode: 'runtime-service-unavailable' }); + expect(invalidated).toBe(true); + }); + + it.each(['ai-text-behavior-unsupported', 'ai-text-output-incomplete', 'ai-tool-call-invalid'])('preserves the typed text failure %s', async (reasonCode) => { + const candidate = binding([]); + candidate.localAppScenarioExecute = async () => ({ status: 'error', reasonCode, retryable: false }); + const host = createNimiElectronLocalAppHostForBinding(candidate); + await expect(host.scenarioExecute({ spec: { type: 'text-generate', messages: [{ role: 'user', text: 'Hello' }] } })).rejects.toMatchObject({ reasonCode }); + }); + it('bootstraps and rotates only the request-empty technical session', async () => { vi.useFakeTimers(); try { @@ -615,6 +655,95 @@ describe('Electron protected local-app host', () => { .resolves.toEqual({ completed: false, event }); }); + it('projects function-tool text output and stream items without treating business JSON as authority', async () => { + const toolCall = { id: 'call-1', name: 'search', arguments: { token: 'business data' } }; + const output = { type: 'text-generate', items: [{ type: 'tool-call', toolCall }], finishReason: 'tool-calls' }; + const events = [ + { type: 'delta', sequence: '1', traceId: 'trace-tools', itemIndex: 0, text: 'Searching. ' }, + { type: 'tool-call', sequence: '2', traceId: 'trace-tools', itemIndex: 1, toolCall }, + { type: 'completed', sequence: '3', traceId: 'trace-tools', finishReason: 'tool-calls' }, + ]; + let index = 0; + const host = createNimiElectronLocalAppHostForBinding({ + ...binding([]), + localAppScenarioExecute: async () => ({ status: 'ok' as const, value: { output, traceId: 'trace-tools' } }), + localAppTextTurnStreamNext: async () => ({ status: 'ok' as const, value: { completed: false, event: events[index++] } }), + }); + await expect(host.scenarioExecute({ spec: {} })).resolves.toEqual({ output, traceId: 'trace-tools' }); + await host.textTurnSubscribe({ messages: [{ role: 'user', text: 'Search.' }] }); + for (const event of events) { + await expect(host.textTurnStreamNext({ streamId: 'text-turn-1' })).resolves.toEqual({ completed: false, event }); + } + await host.textTurnStreamClose({ streamId: 'text-turn-1' }); + }); + + it('preserves typed text interruption and rejects provider fields on tool calls', async () => { + const event = { type: 'failed', sequence: '1', traceId: 'trace-interrupted', reasonCode: 'ai-execution-interrupted', actionHint: 'retry', + interruption: { cause: 'runtime-restart', resubmitDisposition: 'caller-may-resubmit' } }; + const host = createNimiElectronLocalAppHostForBinding({ + ...binding([]), + localAppTextTurnStreamNext: async () => ({ status: 'ok' as const, value: { completed: false, event } }), + localAppScenarioExecute: async () => ({ status: 'ok' as const, value: { + output: { type: 'text-generate', items: [{ type: 'tool-call', toolCall: { id: 'c1', name: 'search', arguments: {}, providerMetadata: {} } }], finishReason: 'tool-calls' }, traceId: 'trace-invalid', + } }), + }); + await host.textTurnSubscribe({ messages: [{ role: 'user', text: 'Search.' }] }); + await expect(host.textTurnStreamNext({ streamId: 'text-turn-1' })).resolves.toEqual({ completed: false, event }); + await expect(host.scenarioExecute({ spec: {} })).rejects.toThrow(); + }); + + it('carries bounded opaque continuity in ordered sync and stream outputs', async () => { + const carrier = { kind: 'test.encrypted', version: 1, payload: [0, 255] }; + const event = { type: 'reasoning-continuity', sequence: '1', traceId: 'trace-continuity', itemIndex: 0, carrier }; + const output = { type: 'text-generate', items: [{ type: 'reasoning-continuity', carrier }, { type: 'text', text: 'Answer.' }], finishReason: 'stop' }; + const host = createNimiElectronLocalAppHostForBinding({ + ...binding([]), + localAppScenarioExecute: async () => ({ status: 'ok' as const, value: { output, traceId: 'trace-continuity' } }), + localAppTextTurnStreamNext: async () => ({ status: 'ok' as const, value: { completed: false, event } }), + }); + await expect(host.scenarioExecute({ spec: {} })).resolves.toEqual({ output, traceId: 'trace-continuity' }); + await host.textTurnSubscribe({ messages: [{ role: 'user', text: 'Answer.' }] }); + await expect(host.textTurnStreamNext({ streamId: 'text-turn-1' })).resolves.toEqual({ completed: false, event }); + output.items.pop(); + await expect(host.scenarioExecute({ spec: {} })).rejects.toThrow(); + carrier.payload.push(256); + await host.textTurnStreamClose({ streamId: 'text-turn-1' }); + await host.textTurnSubscribe({ messages: [{ role: 'user', text: 'Answer.' }] }); + await expect(host.textTurnStreamNext({ streamId: 'text-turn-1' })).rejects.toThrow(); + }); + + it('preserves sync and streamed native content when JSON representation expands', async () => { + const payload = new TextEncoder().encode(JSON.stringify({ + type: 'reasoning', id: 'rs_budget', encrypted_content: 'A'.repeat(60 * 1024), summary: [], + })); + const carrier = { kind: 'openai_codex.responses.encrypted-reasoning', version: 1, payload: Array.from(payload) }; + const rawArguments = `{"values":[${Array(16 * 1024).fill('1e20').join(',')}]}`; + const text = 'x'.repeat(100 * 1024); + for (const args of [{ query: 'Nimi' }, JSON.parse(rawArguments)]) { + const toolCall = { id: 'call-budget', name: 'search', arguments: args }; + const output = { type: 'text-generate', items: [{ type: 'reasoning-continuity', carrier }, { type: 'tool-call', toolCall }, { type: 'text', text }], finishReason: 'tool-calls' }; + const events = [ + { type: 'reasoning-continuity', sequence: '1', traceId: 'trace-budget', itemIndex: 0, carrier }, + { type: 'tool-call', sequence: '2', traceId: 'trace-budget', itemIndex: 1, toolCall }, + ...Array.from({ length: Math.ceil(text.length / (64 * 1024)) }, (_, index) => ({ + type: 'delta', sequence: String(index + 3), traceId: 'trace-budget', itemIndex: 2, text: text.slice(index * 64 * 1024, (index + 1) * 64 * 1024), + })), + ]; + let index = 0; + const host = createNimiElectronLocalAppHostForBinding({ + ...binding([]), + localAppScenarioExecute: async () => ({ status: 'ok' as const, value: { output, traceId: 'trace-budget' } }), + localAppTextTurnStreamNext: async () => ({ status: 'ok' as const, value: { completed: false, event: events[index++] } }), + }); + await expect(host.scenarioExecute({ spec: {} })).resolves.toEqual({ output, traceId: 'trace-budget' }); + await host.textTurnSubscribe({ messages: [{ role: 'user', text: 'Answer.' }] }); + for (let offset = 0; offset < events.length; offset++) { + await expect(host.textTurnStreamNext({ streamId: 'text-turn-1' })).resolves.toEqual({ completed: false, event: events[offset] }); + } + await host.textTurnStreamClose({ streamId: 'text-turn-1' }); + } + }); + it('resolves only independently admitted fixed native binding package identities', () => { expect(resolveNimiElectronProtectedLocalBindingPackage('win32', 'x64')).toBe( '@nimiplatform/kit-protected-local-win32-x64', @@ -689,7 +818,7 @@ function binding(calls: Array<{ method: string; input?: unknown }>) { localAppTextTurnStreamNext: record('localAppTextTurnStreamNext', { completed: true }), localAppTextTurnStreamClose: record('localAppTextTurnStreamClose', { closed: true }), localAppScenarioExecute: record('localAppScenarioExecute', { - output: { type: 'text-embed', vectors: [[0.1, 0.2]] }, traceId: 'trace-1', + output: { type: 'text-embed', vectors: [[0.1, 0.2]], spaceId: 'space-test-1' }, traceId: 'trace-1', }), localAppScenarioJobSubmit: record('localAppScenarioJobSubmit', { job: scenarioJobProjection() }), localAppScenarioJobGet: record('localAppScenarioJobGet', { job: scenarioJobProjection(), asset: null, voiceReference: null }), diff --git a/kit/shell/protected-local-node/Cargo.lock b/kit/shell/protected-local-node/Cargo.lock index 9d6651da2d..f899eccd2f 100644 --- a/kit/shell/protected-local-node/Cargo.lock +++ b/kit/shell/protected-local-node/Cargo.lock @@ -840,7 +840,7 @@ dependencies = [ [[package]] name = "nimi-shell-protected-local" -version = "0.3.0" +version = "0.4.0" dependencies = [ "base64", "cc", @@ -869,7 +869,7 @@ dependencies = [ [[package]] name = "nimi-shell-protected-local-node" -version = "0.3.0" +version = "0.4.0" dependencies = [ "base64", "napi", diff --git a/kit/shell/protected-local-node/Cargo.toml b/kit/shell/protected-local-node/Cargo.toml index 8ba360fb6d..024cce5b36 100644 --- a/kit/shell/protected-local-node/Cargo.toml +++ b/kit/shell/protected-local-node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nimi-shell-protected-local-node" -version = "0.3.0" +version = "0.4.0" edition = "2021" description = "Node-API projection of the Nimi protected-local Local App carrier." license = "MIT" @@ -21,7 +21,7 @@ windows-source-local-development = ["nimi-shell-protected-local/windows-source-l base64 = "0.22" napi = { version = "3", features = ["napi8", "tokio_rt", "serde-json"] } napi-derive = "3" -nimi-shell-protected-local = { version = "0.3.0", path = "../protected-local" } +nimi-shell-protected-local = { version = "0.4.0", path = "../protected-local" } serde_json = "1" tokio = { version = "1", features = ["macros", "sync", "time"] } diff --git a/kit/shell/protected-local-node/npm/darwin-arm64/package.json b/kit/shell/protected-local-node/npm/darwin-arm64/package.json index f54551fe02..ec4208f74d 100644 --- a/kit/shell/protected-local-node/npm/darwin-arm64/package.json +++ b/kit/shell/protected-local-node/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@nimiplatform/kit-protected-local-darwin-arm64", - "version": "0.7.0", + "version": "0.8.0", "private": false, "description": "Fixed macOS arm64 Node-API projection for Nimi protected-local carriers.", "main": "index.cjs", @@ -15,8 +15,12 @@ "access": "public", "provenance": true }, - "os": ["darwin"], - "cpu": ["arm64"], + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], "engines": { "node": ">=22" }, diff --git a/kit/shell/protected-local-node/npm/win32-x64/package.json b/kit/shell/protected-local-node/npm/win32-x64/package.json index 35ba72cbc6..da38a4625a 100644 --- a/kit/shell/protected-local-node/npm/win32-x64/package.json +++ b/kit/shell/protected-local-node/npm/win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@nimiplatform/kit-protected-local-win32-x64", - "version": "0.7.0", + "version": "0.8.0", "description": "Nimi Kit protected-local Node-API carrier for Windows x64 Electron hosts", "license": "MIT", "private": false, diff --git a/kit/shell/protected-local-node/src/lib.rs b/kit/shell/protected-local-node/src/lib.rs index 7eadcc0bb5..e11d1573ee 100644 --- a/kit/shell/protected-local-node/src/lib.rs +++ b/kit/shell/protected-local-node/src/lib.rs @@ -55,7 +55,7 @@ use nimi_shell_protected_local::{ LocalAppScenarioUploadArtifactRequest, LocalAppSessionStatus, LocalAppSharedAgentAIConfigLocalOptionsRequest, LocalAppSharedAgentAIConfigOverwriteRequest, LocalAppStorageReadRequest, LocalAppStorageRemoveRequest, LocalAppStorageWriteRequest, - LocalAppTextCandidateMessage, LocalAppTextCandidateRequest, LocalAppTextTurnRequest, + LocalAppTextCandidateMessage, LocalAppTextCandidateRequest, LocalAppTextMessage, LocalAppTextTurnRequest, LocalAppWorldCoreCreateRequest, LocalAppWorldCoreListRequest, LocalDevelopmentEndRunRequest, LocalDevelopmentLaunchRequest, LocalDevelopmentRegistration, LocalDevelopmentRegistrationRequest, LocalDevelopmentShellKind, NimiDesktopControl, diff --git a/kit/shell/protected-local-node/src/local_app.rs b/kit/shell/protected-local-node/src/local_app.rs index 128b136f14..d4b9234e87 100644 --- a/kit/shell/protected-local-node/src/local_app.rs +++ b/kit/shell/protected-local-node/src/local_app.rs @@ -776,9 +776,10 @@ fn native_text_turn_request( messages: input .messages .into_iter() - .map(|message| LocalAppTextCandidateMessage { + .map(|message| LocalAppTextMessage { role: message.role, text: message.text, + turn_items: message.turn_items.unwrap_or_default(), }) .collect(), temperature: input.temperature.map(|value| value as f32), @@ -789,6 +790,9 @@ fn native_text_turn_request( frequency_penalty: input.frequency_penalty.map(|value| value as f32), stop: input.stop.unwrap_or_default(), seed: optional_native_i64(input.seed)?, + tools: input.tools.unwrap_or_default(), + tool_choice: input.tool_choice, + response_format: input.response_format, }) } @@ -2396,9 +2400,10 @@ mod session_rebind_tests { #[test] fn native_text_turn_conversion_preserves_extended_sampling() { let request = native_text_turn_request(NativeTextTurnInput { - messages: vec![NativeTextCandidateMessage { + messages: vec![NativeTextTurnMessage { role: "user".to_string(), text: "hello".to_string(), + turn_items: None, }], temperature: Some(0.0), top_p: None, @@ -2408,6 +2413,9 @@ mod session_rebind_tests { frequency_penalty: Some(2.0), stop: Some(vec!["END".to_string()]), seed: Some(0.0), + tools: None, + tool_choice: None, + response_format: None, }) .expect("native optional parameters"); assert_eq!(request.top_k, Some(0)); diff --git a/kit/shell/protected-local-node/src/native_types.rs b/kit/shell/protected-local-node/src/native_types.rs index d95853dd9e..6fbf15cacd 100644 --- a/kit/shell/protected-local-node/src/native_types.rs +++ b/kit/shell/protected-local-node/src/native_types.rs @@ -199,9 +199,16 @@ pub struct NativeTextCandidateInput { pub max_tokens: Option, } +#[napi(object)] +pub struct NativeTextTurnMessage { + pub role: String, + pub text: String, + pub turn_items: Option>, +} + #[napi(object)] pub struct NativeTextTurnInput { - pub messages: Vec, + pub messages: Vec, pub temperature: Option, pub top_p: Option, pub max_tokens: Option, @@ -210,6 +217,9 @@ pub struct NativeTextTurnInput { pub frequency_penalty: Option, pub stop: Option>, pub seed: Option, + pub tools: Option>, + pub tool_choice: Option, + pub response_format: Option, } #[napi(object)] diff --git a/kit/shell/protected-local/Cargo.lock b/kit/shell/protected-local/Cargo.lock index 874f71c96f..211164a1b7 100644 --- a/kit/shell/protected-local/Cargo.lock +++ b/kit/shell/protected-local/Cargo.lock @@ -706,7 +706,7 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "nimi-shell-protected-local" -version = "0.3.0" +version = "0.4.0" dependencies = [ "base64", "cc", diff --git a/kit/shell/protected-local/Cargo.toml b/kit/shell/protected-local/Cargo.toml index fbe3c325c5..42cfdafa72 100644 --- a/kit/shell/protected-local/Cargo.toml +++ b/kit/shell/protected-local/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nimi-shell-protected-local" -version = "0.3.0" +version = "0.4.0" edition = "2021" description = "Native host-only protected-local Runtime carrier contract for Nimi desktop shells." license = "MIT" diff --git a/kit/shell/protected-local/proto/runtime/v1/ai.proto b/kit/shell/protected-local/proto/runtime/v1/ai.proto index b332271238..bd2e1ea6d6 100644 --- a/kit/shell/protected-local/proto/runtime/v1/ai.proto +++ b/kit/shell/protected-local/proto/runtime/v1/ai.proto @@ -692,6 +692,9 @@ message EmbeddingVector { message TextEmbedOutput { repeated EmbeddingVector vectors = 1; + // Opaque compatibility identity of the captured embedding semantics. + // Compare actual result values before combining vectors or reusing an index. + string space_id = 2; } message ImageGenerateResult { @@ -779,6 +782,9 @@ message ExecuteScenarioResponse { message LocalAppTextCandidateMessage { string role = 1; string text = 2; + // Ordered assistant transcript is admitted only by text-turn and the + // text.generate execute variant. The narrow candidate operation rejects it. + repeated TextTurnItem turn_items = 3; } message GenerateLocalAppTextCandidateRequest { @@ -799,8 +805,8 @@ message GenerateLocalAppTextCandidateResponse { // Exact third-party Local App scenario-consumption contracts. Runtime derives // account, App identity, App Access and managed route composition from the // protected session and the calling App owner's committed AIConfig; callers -// cannot supply generic Scenario head, route, provider, model, target, or tool -// fields. +// cannot supply generic Scenario head, route, provider, model, or target fields. +// Function tools and structured output are admitted only by the text variant. // Trimmed artifact projection for Local App scenario outputs. Runtime-private // uri, producer, and owner fields are never projected; oversized payloads are @@ -847,21 +853,31 @@ message ExecuteLocalAppScenarioRequest { oneof spec { LocalAppTextEmbedScenarioSpec text_embed = 1; LocalAppImageGenerateScenarioSpec image_generate = 2; + // Reuse the single text-turn input for synchronous text generation. + StreamLocalAppTextTurnRequest text_generate = 3; } } message LocalAppTextEmbedOutput { repeated EmbeddingVector vectors = 1; + string space_id = 2; } message LocalAppImageGenerateOutput { repeated LocalAppScenarioArtifact artifacts = 1; } +message LocalAppTextGenerateOutput { + // Only text and function ToolCall items are admitted at this App boundary. + repeated TextOutputItem items = 1; + FinishReason finish_reason = 2; +} + message ExecuteLocalAppScenarioResponse { oneof output { LocalAppTextEmbedOutput text_embed = 1; LocalAppImageGenerateOutput image_generate = 2; + LocalAppTextGenerateOutput text_generate = 4; } string trace_id = 3; } @@ -1040,9 +1056,9 @@ message LocalAppScenarioJobEvent { LocalAppScenarioJob job = 5; } -// Streaming TEXT_GENERATE trimmed surface: typed text increments and terminal -// finish or failure state only. Raw chunks, reasoning traces, sources, and -// tool events never reach the Local App stream. +// Single-step TEXT_GENERATE App surface. Function tools, ordered host-provided +// results and structured output reuse canonical text behavior. The external +// AI host owns tool execution and the next request; Runtime owns no agent loop. message StreamLocalAppTextTurnRequest { repeated LocalAppTextCandidateMessage messages = 1; optional float temperature = 2; @@ -1053,10 +1069,26 @@ message StreamLocalAppTextTurnRequest { optional float frequency_penalty = 7; repeated string stop = 8; optional int64 seed = 9; + repeated ToolSpec tools = 10; + ToolChoiceMode tool_choice = 11; + string tool_choice_name = 12; + ResponseFormat response_format = 13; } message LocalAppTextTurnDelta { string text = 1; + uint32 item_index = 2; +} + +message LocalAppTextTurnToolCall { + uint32 item_index = 1; + ToolCall tool_call = 2; +} + +// Opaque transcript continuity, never raw reasoning or a reasoning control. +message LocalAppTextTurnContinuity { + uint32 item_index = 1; + ReasoningContinuityCarrier carrier = 2; } message LocalAppTextTurnCompleted { @@ -1076,6 +1108,8 @@ message StreamLocalAppTextTurnEvent { LocalAppTextTurnDelta delta = 3; LocalAppTextTurnCompleted completed = 4; LocalAppTextTurnFailed failed = 5; + LocalAppTextTurnToolCall tool_call = 6; + LocalAppTextTurnContinuity reasoning_continuity = 7; } } diff --git a/kit/shell/protected-local/src/carrier.rs b/kit/shell/protected-local/src/carrier.rs index bf4bb97cc6..20a1990355 100644 --- a/kit/shell/protected-local/src/carrier.rs +++ b/kit/shell/protected-local/src/carrier.rs @@ -45,6 +45,12 @@ pub enum LocalAppReasonCode { AiRouteFallbackDenied, AiInputInvalid, AiOutputInvalid, + AiTextBehaviorUnsupported, + AiTextBehaviorAmbiguous, + AiTextOutputIncomplete, + AiToolCallInvalid, + AiReasoningContinuityInvalid, + AiExecutionInterrupted, AiContentFilterBlocked, AiLocalModelUnavailable, AiLocalModelProfileMissing, @@ -151,6 +157,12 @@ impl LocalAppReasonCode { Self::AiRouteFallbackDenied => "ai-route-fallback-denied", Self::AiInputInvalid => "ai-input-invalid", Self::AiOutputInvalid => "ai-output-invalid", + Self::AiTextBehaviorUnsupported => "ai-text-behavior-unsupported", + Self::AiTextBehaviorAmbiguous => "ai-text-behavior-ambiguous", + Self::AiTextOutputIncomplete => "ai-text-output-incomplete", + Self::AiToolCallInvalid => "ai-tool-call-invalid", + Self::AiReasoningContinuityInvalid => "ai-reasoning-continuity-invalid", + Self::AiExecutionInterrupted => "ai-execution-interrupted", Self::AiContentFilterBlocked => "ai-content-filter-blocked", Self::AiLocalModelUnavailable => "ai-local-model-unavailable", Self::AiLocalModelProfileMissing => "ai-local-model-profile-missing", @@ -332,17 +344,33 @@ pub struct LocalAppTextCandidateRequest { pub max_tokens: Option, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LocalAppTextMessage { + pub role: String, + #[serde(default)] + pub text: String, + #[serde(default)] + pub turn_items: Vec, +} + +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct LocalAppTextTurnRequest { - pub messages: Vec, + pub messages: Vec, pub temperature: Option, pub top_p: Option, pub max_tokens: Option, pub top_k: Option, pub presence_penalty: Option, pub frequency_penalty: Option, + #[serde(default)] pub stop: Vec, pub seed: Option, + #[serde(default)] + pub tools: Vec, + pub tool_choice: Option, + pub response_format: Option, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/kit/shell/protected-local/src/grpc_status.rs b/kit/shell/protected-local/src/grpc_status.rs index 57509d2fbb..7bf65062a2 100644 --- a/kit/shell/protected-local/src/grpc_status.rs +++ b/kit/shell/protected-local/src/grpc_status.rs @@ -162,6 +162,12 @@ pub(crate) fn local_app_reason_from_proto(value: i32) -> Option LocalAppReasonCode::AiRouteFallbackDenied, 206 => LocalAppReasonCode::AiInputInvalid, 207 => LocalAppReasonCode::AiOutputInvalid, + 395 => LocalAppReasonCode::AiTextBehaviorUnsupported, + 396 => LocalAppReasonCode::AiTextBehaviorAmbiguous, + 397 => LocalAppReasonCode::AiTextOutputIncomplete, + 398 => LocalAppReasonCode::AiToolCallInvalid, + 399 => LocalAppReasonCode::AiReasoningContinuityInvalid, + 402 => LocalAppReasonCode::AiExecutionInterrupted, 209 => LocalAppReasonCode::AiContentFilterBlocked, 352 => LocalAppReasonCode::AiLocalModelUnavailable, 353 => LocalAppReasonCode::AiLocalModelProfileMissing, @@ -298,6 +304,12 @@ fn local_app_reason_from_runtime_reason(value: &str) -> Option LocalAppReasonCode::AiRouteFallbackDenied, "AI_INPUT_INVALID" => LocalAppReasonCode::AiInputInvalid, "AI_OUTPUT_INVALID" => LocalAppReasonCode::AiOutputInvalid, + "AI_TEXT_BEHAVIOR_UNSUPPORTED" => LocalAppReasonCode::AiTextBehaviorUnsupported, + "AI_TEXT_BEHAVIOR_AMBIGUOUS" => LocalAppReasonCode::AiTextBehaviorAmbiguous, + "AI_TEXT_OUTPUT_INCOMPLETE" => LocalAppReasonCode::AiTextOutputIncomplete, + "AI_TOOL_CALL_INVALID" => LocalAppReasonCode::AiToolCallInvalid, + "AI_REASONING_CONTINUITY_INVALID" => LocalAppReasonCode::AiReasoningContinuityInvalid, + "AI_EXECUTION_INTERRUPTED" => LocalAppReasonCode::AiExecutionInterrupted, "AI_CONTENT_FILTER_BLOCKED" => LocalAppReasonCode::AiContentFilterBlocked, "AI_LOCAL_MODEL_UNAVAILABLE" => LocalAppReasonCode::AiLocalModelUnavailable, "AI_LOCAL_MODEL_PROFILE_MISSING" => LocalAppReasonCode::AiLocalModelProfileMissing, @@ -483,6 +495,21 @@ fn status_is_retryable(code: Code) -> bool { mod tests { use super::*; + #[test] + fn protected_text_behavior_failures_stay_typed() { + for (name, number, reason) in [ + ("AI_TEXT_BEHAVIOR_UNSUPPORTED", 395, LocalAppReasonCode::AiTextBehaviorUnsupported), + ("AI_TEXT_BEHAVIOR_AMBIGUOUS", 396, LocalAppReasonCode::AiTextBehaviorAmbiguous), + ("AI_TEXT_OUTPUT_INCOMPLETE", 397, LocalAppReasonCode::AiTextOutputIncomplete), + ("AI_TOOL_CALL_INVALID", 398, LocalAppReasonCode::AiToolCallInvalid), + ("AI_REASONING_CONTINUITY_INVALID", 399, LocalAppReasonCode::AiReasoningContinuityInvalid), + ("AI_EXECUTION_INTERRUPTED", 402, LocalAppReasonCode::AiExecutionInterrupted), + ] { + assert_eq!(local_app_reason_from_proto(number), Some(reason)); + assert_eq!(local_app_reason_from_runtime_reason(name), Some(reason)); + } + } + #[test] fn protected_access_unavailable_stays_typed() { assert_eq!( diff --git a/kit/shell/protected-local/src/lib.rs b/kit/shell/protected-local/src/lib.rs index 04e17c937d..647e30a06b 100644 --- a/kit/shell/protected-local/src/lib.rs +++ b/kit/shell/protected-local/src/lib.rs @@ -167,7 +167,7 @@ pub use carrier::{ LocalAppSharedAgentAIConfigOverwriteRequest, LocalAppStorageDocument, LocalAppStorageReadRequest, LocalAppStorageRemoveRequest, LocalAppStorageRemoveResult, LocalAppStorageWriteRequest, LocalAppTextCandidateMessage, LocalAppTextCandidateRequest, - LocalAppTextCandidateResult, LocalAppTextTurnRequest, LocalAppWorldCoreCreateRequest, + LocalAppTextCandidateResult, LocalAppTextMessage, LocalAppTextTurnRequest, LocalAppWorldCoreCreateRequest, LocalAppWorldCoreListRequest, NimiDesktopControl, NimiLocalAppCarrier, NimiLocalAppSession, NimiProtectedLocalHostCarrier, }; diff --git a/kit/shell/protected-local/src/macos_supervised_process.rs b/kit/shell/protected-local/src/macos_supervised_process.rs index f31ec2db70..0c66823b32 100644 --- a/kit/shell/protected-local/src/macos_supervised_process.rs +++ b/kit/shell/protected-local/src/macos_supervised_process.rs @@ -94,6 +94,11 @@ impl SupervisedDevelopmentProcess { .collect::>(); argv.push(std::ptr::null_mut()); let environment_values = sanitized_environment()?; + let environment_values = if installed { + installed_app_environment(environment_values) + } else { + environment_values + }; let mut envp = environment_values .iter() .map(|value| value.as_ptr().cast_mut()) @@ -246,6 +251,19 @@ fn path_cstring(path: &Path) -> Result { CString::new(path.as_os_str().as_bytes()).map_err(|_| untrusted()) } +fn installed_app_environment(environment: Vec) -> Vec { + // Packaged Apps load their own fixed resource carrier. The source Runtime + // executable observation remains available to a source-built carrier. + environment + .into_iter() + .filter(|value| { + let value = value.as_bytes(); + !value.starts_with(b"NIMI_MACOS_SOURCE_LOCAL_DEVELOPMENT=") + && !value.starts_with(b"NIMI_MACOS_SOURCE_LOCAL_DEVELOPMENT_NATIVE_ENTRY=") + }) + .collect() +} + fn sanitized_environment() -> Result, NimiHostError> { #[cfg(feature = "macos-source-local-development")] { @@ -467,6 +485,30 @@ mod tests { .all(|value| !value.to_ascii_lowercase().contains("token"))); } + #[test] + fn installed_app_uses_its_packaged_carrier_while_retaining_source_runtime_observation() { + let environment = [ + "HOME=/Users/test", + "NIMI_MACOS_SOURCE_LOCAL_DEVELOPMENT=1", + "NIMI_MACOS_SOURCE_LOCAL_DEVELOPMENT_NATIVE_ENTRY=/source/kit/index.cjs", + "NIMI_MACOS_SOURCE_LOCAL_DEVELOPMENT_RUNTIME_EXECUTABLE=/source/nimi-runtime", + ] + .into_iter() + .map(|value| CString::new(value).expect("environment entry")) + .collect(); + let projected = installed_app_environment(environment) + .into_iter() + .map(|value| value.into_string().expect("UTF-8 environment entry")) + .collect::>(); + assert_eq!( + projected, + vec![ + "HOME=/Users/test", + "NIMI_MACOS_SOURCE_LOCAL_DEVELOPMENT_RUNTIME_EXECUTABLE=/source/nimi-runtime", + ] + ); + } + #[cfg(feature = "macos-source-local-development")] #[test] fn source_runtime_executable_rejects_missing_relative_symlink_non_file_unsafe_modes_and_foreign_owner( diff --git a/kit/shell/protected-local/src/windows_local_app.rs b/kit/shell/protected-local/src/windows_local_app.rs index d674504b46..e2c8c2671d 100644 --- a/kit/shell/protected-local/src/windows_local_app.rs +++ b/kit/shell/protected-local/src/windows_local_app.rs @@ -13,6 +13,7 @@ mod scenario; mod shared_agent_ai_config; mod storage; mod text_candidate; +mod text_behavior; use std::future::Future; use std::pin::Pin; diff --git a/kit/shell/protected-local/src/windows_local_app/app_ai_config.rs b/kit/shell/protected-local/src/windows_local_app/app_ai_config.rs index 77efbd6c97..3c6d6b85c6 100644 --- a/kit/shell/protected-local/src/windows_local_app/app_ai_config.rs +++ b/kit/shell/protected-local/src/windows_local_app/app_ai_config.rs @@ -259,7 +259,7 @@ pub(super) fn project_cloud_target( "label": required_text_value(&resource.label)?, "capabilityContract": required_text_value(&resource.capability_contract)?, "implementation": project_implementation(resource.implementation.ok_or_else(untrusted)?)?, - "providerModelTarget": project_proto_struct(resource.provider_model_target.ok_or_else(untrusted)?)?, + "providerModelTarget": project_target_json(resource.provider_model_target.ok_or_else(untrusted)?)?, "supportedFeatures": resource.supported_features, "state": project_effective_state(resource.state)?, "reasons": resource.reasons, @@ -703,6 +703,26 @@ pub(super) fn project_capability( Ok(JsonValue::Object(projected)) } +// Options expose the SDK's plain JSON target. Committed portable intent below +// retains its generated Struct representation; the two are distinct contracts. +fn project_target_json(value: ProtoStruct) -> Result { + Ok(JsonValue::Object(value.fields.into_iter() + .map(|(key, value)| Ok((key, project_target_value(value)?))) + .collect::, LocalAppOperationError>>()?)) +} + +fn project_target_value(value: ProtoValue) -> Result { + match value.kind.ok_or_else(untrusted)? { + ProtoValueKind::NullValue(0) => Ok(JsonValue::Null), + ProtoValueKind::NumberValue(value) if value.is_finite() => Ok(json!(value)), + ProtoValueKind::StringValue(value) => Ok(JsonValue::String(value)), + ProtoValueKind::BoolValue(value) => Ok(JsonValue::Bool(value)), + ProtoValueKind::StructValue(value) => project_target_json(value), + ProtoValueKind::ListValue(value) => Ok(JsonValue::Array(value.values.into_iter().map(project_target_value).collect::, _>>()?)), + _ => Err(untrusted()), + } +} + fn project_proto_struct(value: ProtoStruct) -> Result { let fields = value .fields @@ -800,6 +820,21 @@ fn untrusted() -> LocalAppOperationError { #[cfg(test)] mod tests { use super::*; + + #[test] + fn cloud_options_project_plain_target_json_for_model_selection() { + let target = ProtoStruct { fields: BTreeMap::from([ + ("provider".into(), ProtoValue { kind: Some(ProtoValueKind::StringValue("provider-test".into())) }), + ("providerModelId".into(), ProtoValue { kind: Some(ProtoValueKind::StringValue("model-1".into())) }), + ("remoteModelCatalogId".into(), ProtoValue { kind: Some(ProtoValueKind::StringValue("catalog-1".into())) }), + ]) }; + let projected = project_cloud_target(AiConfigCloudTargetProjection { + connector_ref: "connector-1".into(), label: "model-1".into(), capability_contract: "text.generate".into(), + implementation: Some(CapabilityImplementationIdentity { implementation_id: "provider-test".into(), driver_id: "nimillm".into(), driver_dialect: "provider-test".into() }), + provider_model_target: Some(target), state: AiConfigEffectiveState::Ready as i32, ..Default::default() + }).unwrap(); + assert_eq!(projected["providerModelTarget"], json!({"provider":"provider-test", "providerModelId":"model-1", "remoteModelCatalogId":"catalog-1"})); + } use crate::generated::{AiConfigAppOwner, AiConfigOwner}; fn overwrite_response() -> OverwriteAppAiConfigResponse { diff --git a/kit/shell/protected-local/src/windows_local_app/scenario.rs b/kit/shell/protected-local/src/windows_local_app/scenario.rs index d1e3378ffc..cfcac00b59 100644 --- a/kit/shell/protected-local/src/windows_local_app/scenario.rs +++ b/kit/shell/protected-local/src/windows_local_app/scenario.rs @@ -24,7 +24,6 @@ use crate::generated::{ LocalAppVoiceCreateJobSpec, LocalAppWorldGenerateJobSpec, ReadLocalAppArtifactRequest as ProtoReadArtifactRequest, ScenarioJobEventType, ScenarioJobStatus, ScenarioType, SpeechTimingMode, SpeechTranscriptionAudioSource, - StreamLocalAppTextTurnRequest as ProtoTextTurnRequest, SubmitLocalAppScenarioJobRequest as ProtoSubmitJobRequest, SubscribeLocalAppScenarioJobEventsRequest, UploadLocalAppArtifactRequest as ProtoUploadArtifactRequest, VideoContentArtifactRef, @@ -42,7 +41,7 @@ use crate::{ LocalAppScenarioUploadArtifactRequest, LocalAppTextTurnRequest, }; -use super::{invalid_payload, text_candidate, untrusted}; +use super::{invalid_payload, text_behavior, untrusted}; const UNARY_TIMEOUT_SECONDS: u64 = 120; const MAX_ARTIFACT_BYTES: usize = crate::RUNTIME_MAX_INLINE_PAYLOAD_BYTES; @@ -72,6 +71,7 @@ pub(super) async fn execute( valid_runtime_text(&response.trace_id, MAX_TRACE_BYTES)?; let output = match response.output.ok_or_else(untrusted)? { ExecuteOutput::TextEmbed(value) => { + valid_runtime_text(&value.space_id, MAX_IDENTIFIER_BYTES)?; if value.vectors.is_empty() || value.vectors.len() > 16 { return Err(untrusted()); } @@ -90,8 +90,9 @@ pub(super) async fn execute( )) }) .collect::, LocalAppOperationError>>()?; - json!({"type": "text-embed", "vectors": vectors}) + json!({"type": "text-embed", "vectors": vectors, "spaceId": value.space_id}) } + ExecuteOutput::TextGenerate(value) => text_behavior::project_output(value)?, ExecuteOutput::ImageGenerate(value) => json!({ "type": "image-generate", "artifacts": project_artifacts(value.artifacts)?, @@ -221,58 +222,37 @@ pub(super) async fn stream_text_turn( channel: Channel, request: LocalAppTextTurnRequest, ) -> Result { - text_candidate::validate_text_input( - &request.messages, - request.temperature, - request.top_p, - request.max_tokens, - )?; - if request.top_k.is_some_and(|value| value < 0) - || request - .presence_penalty - .is_some_and(|value| !value.is_finite() || !(-2.0..=2.0).contains(&value)) - || request - .frequency_penalty - .is_some_and(|value| !value.is_finite() || !(-2.0..=2.0).contains(&value)) - || request.stop.iter().any(|value| value.trim().is_empty()) - { - return Err(invalid_payload()); - } - let mut stream = crate::grpc_limits::runtime_ai_client(channel) - .stream_local_app_text_turn(ProtoTextTurnRequest { - messages: request - .messages - .into_iter() - .map(|message| crate::generated::LocalAppTextCandidateMessage { - role: message.role, - text: message.text, - }) - .collect(), - temperature: request.temperature, - top_p: request.top_p, - max_tokens: request.max_tokens, - top_k: request.top_k, - presence_penalty: request.presence_penalty, - frequency_penalty: request.frequency_penalty, - stop: request.stop, - seed: request.seed, - }) - .await - .map_err(local_app_error_from_status)? - .into_inner(); + let request = text_behavior::request(request)?; let (sender, receiver) = mpsc::channel(32); tokio::spawn(async move { + let mut client = crate::grpc_limits::runtime_ai_client(channel); + let opened = tokio::select! { + _ = sender.closed() => return, + opened = client.stream_local_app_text_turn(request) => opened, + }; + let mut stream = match opened { + Ok(response) => response.into_inner(), + Err(status) => { + let _ = sender.send(Err(local_app_error_from_status(status))).await; + return; + } + }; let mut expected_sequence = 1u64; let mut total_delta_bytes = 0usize; loop { - match stream.message().await { + let next = tokio::select! { + _ = sender.closed() => break, + next = stream.message() => next, + }; + match next { Ok(Some(event)) => { let projected = project_text_turn_event(event, expected_sequence, &mut total_delta_bytes); + let terminal = projected.as_ref().map(|value| matches!(value.get("type").and_then(JsonValue::as_str), Some("completed" | "failed"))).unwrap_or(true); if projected.is_ok() { expected_sequence += 1; } - if sender.send(projected).await.is_err() { + if sender.send(projected).await.is_err() || terminal { break; } } @@ -376,8 +356,14 @@ pub(super) async fn list_voice_assets( } fn parse_execute_spec(value: JsonValue) -> Result { - let object = exact_object(value)?; - match string_field(&object, "type")? { + let mut object = exact_object(value)?; + let kind = string_field(&object, "type")?.to_string(); + match kind.as_str() { + "text-generate" => { + object.remove("type"); + let input: LocalAppTextTurnRequest = serde_json::from_value(JsonValue::Object(object)).map_err(|_| invalid_payload())?; + Ok(ExecuteSpec::TextGenerate(text_behavior::request(input)?)) + } "text-embed" => { exact_keys(&object, &["type", "inputs"])?; let inputs = string_array(field(&object, "inputs")?, 16, MAX_PROMPT_BYTES, false)?; @@ -1268,16 +1254,25 @@ fn project_text_turn_event( .filter(|total| *total <= 256 * 1024) .ok_or_else(untrusted)?; Ok( - json!({"type": "delta", "sequence": event.sequence.to_string(), "traceId": event.trace_id, "text": value.text}), + json!({"type": "delta", "sequence": event.sequence.to_string(), "traceId": event.trace_id, "text": value.text, "itemIndex": value.item_index}), ) } + TextTurnPayload::ToolCall(value) => { + let call = value.tool_call.ok_or_else(untrusted)?; + let bytes = prost::Message::encoded_len(&call); + let call = text_behavior::project_tool_call(call)?; + *total_delta_bytes = total_delta_bytes.checked_add(bytes).filter(|total| *total <= 256 * 1024).ok_or_else(untrusted)?; + Ok(json!({"type": "tool-call", "sequence": event.sequence.to_string(), "traceId": event.trace_id, "itemIndex": value.item_index, "toolCall": call})) + } + TextTurnPayload::ReasoningContinuity(value) => { + let carrier = value.carrier.ok_or_else(untrusted)?; + let bytes = prost::Message::encoded_len(&carrier); + let carrier = text_behavior::project_continuity(carrier)?; + *total_delta_bytes = total_delta_bytes.checked_add(bytes).filter(|total| *total <= 256 * 1024).ok_or_else(untrusted)?; + Ok(json!({"type": "reasoning-continuity", "sequence": event.sequence.to_string(), "traceId": event.trace_id, "itemIndex": value.item_index, "carrier": carrier})) + } TextTurnPayload::Completed(value) => { - let finish_reason = match value.finish_reason { - 1 => "stop", - 2 => "length", - 4 => "content-filter", - _ => return Err(untrusted()), - }; + let finish_reason = text_behavior::finish_reason(value.finish_reason)?; Ok( json!({"type": "completed", "sequence": event.sequence.to_string(), "traceId": event.trace_id, "finishReason": finish_reason}), ) @@ -1609,6 +1604,40 @@ fn valid_page_token(value: &str) -> bool { mod tests { use super::*; + #[test] + fn text_stream_budget_counts_content_before_json_expansion() { + use crate::generated::{LocalAppTextTurnContinuity, LocalAppTextTurnDelta, LocalAppTextTurnToolCall, ReasoningContinuityCarrier, StreamLocalAppTextTurnEvent, ToolCall}; + let carrier = ReasoningContinuityCarrier { + kind: "openai_codex.responses.encrypted-reasoning".into(), version: 1, + payload: serde_json::to_vec(&json!({ + "type": "reasoning", "id": "rs_budget", "encrypted_content": "A".repeat(60 * 1024), "summary": [] + })).unwrap(), + }; + let call = ToolCall { + id: "call-budget".into(), name: "search".into(), + arguments_json: format!("{{\"values\":[{}]}}", vec!["1e20"; 16 * 1024].join(",")), + ..Default::default() + }; + let mut total_bytes = 0; + let mut sequence = 0; + let mut send = |payload| { + sequence += 1; + project_text_turn_event(StreamLocalAppTextTurnEvent { + sequence, trace_id: "trace-budget".into(), payload: Some(payload), + }, sequence, &mut total_bytes) + }; + send(TextTurnPayload::ReasoningContinuity(LocalAppTextTurnContinuity { item_index: 0, carrier: Some(carrier.clone()) })).unwrap(); + send(TextTurnPayload::ToolCall(LocalAppTextTurnToolCall { item_index: 1, tool_call: Some(call.clone()) })).unwrap(); + let remaining = 256 * 1024 - prost::Message::encoded_len(&carrier) - prost::Message::encoded_len(&call); + let mut sent = 0; + while sent < remaining { + let bytes = (remaining - sent).min(50 * 1024); + send(TextTurnPayload::Delta(LocalAppTextTurnDelta { item_index: 2, text: "x".repeat(bytes) })).unwrap(); + sent += bytes; + } + assert!(send(TextTurnPayload::Delta(LocalAppTextTurnDelta { item_index: 2, text: "x".into() })).is_err()); + } + #[test] fn face_replacement_job_has_two_owned_references_and_no_sync_alias() { let input = json!({ diff --git a/kit/shell/protected-local/src/windows_local_app/text_behavior.rs b/kit/shell/protected-local/src/windows_local_app/text_behavior.rs new file mode 100644 index 0000000000..e66a68baa2 --- /dev/null +++ b/kit/shell/protected-local/src/windows_local_app/text_behavior.rs @@ -0,0 +1,493 @@ +use std::collections::BTreeMap; + +use prost_types::{value::Kind, ListValue, Struct, Value as ProtoValue}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::generated::{ + text_output_item, text_turn_item, LocalAppTextCandidateMessage, LocalAppTextGenerateOutput, + ReasoningContinuityCarrier, ResponseFormat, ResponseFormatKind, StreamLocalAppTextTurnRequest, TextOutputItem, + TextOutputText, TextTurnItem, ToolCall, ToolChoiceMode, ToolResult, ToolSpec, ToolSpecKind, +}; +use crate::{LocalAppOperationError, LocalAppTextTurnRequest}; + +use super::{invalid_payload, untrusted}; + +const MAX_REQUEST_BYTES: usize = 1024 * 1024; +const MAX_OUTPUT_BYTES: usize = 256 * 1024; +const MAX_CONTINUITY_BYTES: usize = 64 * 1024; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ContinuityCarrier { + kind: String, + version: u32, + payload: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct FunctionTool { + #[serde(rename = "type")] + kind: Option, + name: String, + #[serde(default)] + description: String, + input_schema: Value, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FunctionCall { + id: String, + name: String, + arguments: Value, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct FunctionResult { + tool_call_id: String, + tool_name: String, + result: Value, + #[serde(default)] + is_error: bool, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)] +enum OutputItem { + ReasoningContinuity { carrier: ContinuityCarrier }, + Text { + text: String, + }, + ToolCall { + #[serde(rename = "toolCall")] + tool_call: FunctionCall, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)] +enum TurnItem { + Output { + output: OutputItem, + }, + ToolResult { + #[serde(rename = "toolResult")] + tool_result: FunctionResult, + }, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NamedChoice { + #[serde(rename = "type")] + kind: String, + name: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StructuredFormat { + #[serde(rename = "type")] + kind: String, + schema: Option, + #[serde(default)] + name: String, + #[serde(default)] + description: String, + #[serde(default)] + strict: bool, +} + +// @nimi-authority: rule.nimi.runtime.ai-provider.local-app-text-behaviors +pub(super) fn request( + input: LocalAppTextTurnRequest, +) -> Result { + if serde_json::to_vec(&input) + .map_err(|_| invalid_payload())? + .len() + > MAX_REQUEST_BYTES + || input.messages.is_empty() + || input.messages.len() > 128 + || input.tools.len() > 64 + || input + .temperature + .is_some_and(|v| !v.is_finite() || !(0.0..=2.0).contains(&v)) + || input + .top_p + .is_some_and(|v| !v.is_finite() || !(0.0..=1.0).contains(&v)) + || input.max_tokens.is_some_and(|v| v < 0) + || input.top_k.is_some_and(|v| v < 0) + || input + .presence_penalty + .is_some_and(|v| !v.is_finite() || !(-2.0..=2.0).contains(&v)) + || input + .frequency_penalty + .is_some_and(|v| !v.is_finite() || !(-2.0..=2.0).contains(&v)) + || input.stop.iter().any(|v| v.trim().is_empty()) + { + return Err(invalid_payload()); + } + let mut saw_user = false; + let messages = input + .messages + .into_iter() + .enumerate() + .map(|(index, message)| { + match message.role.as_str() { + "system" if index == 0 && message.turn_items.is_empty() => {} + "user" if message.turn_items.is_empty() => saw_user = true, + "assistant" => {} + _ => return Err(invalid_payload()), + } + if (message.turn_items.is_empty() && message.text.trim().is_empty()) + || (!message.turn_items.is_empty() && !message.text.is_empty()) + { + return Err(invalid_payload()); + } + Ok(LocalAppTextCandidateMessage { + role: message.role, + text: message.text, + turn_items: message + .turn_items + .into_iter() + .map(parse_turn_item) + .collect::>()?, + }) + }) + .collect::, _>>()?; + if !saw_user { + return Err(invalid_payload()); + } + let tools = input + .tools + .into_iter() + .map(|value| { + let tool: FunctionTool = + serde_json::from_value(value).map_err(|_| invalid_payload())?; + identifier(&tool.name)?; + if tool.kind.as_deref().is_some_and(|kind| kind != "function") { + return Err(invalid_payload()); + } + Ok(ToolSpec { + name: tool.name, + description: tool.description, + input_schema: Some(proto_struct(tool.input_schema)?), + kind: ToolSpecKind::Function as i32, + provider_tool_id: String::new(), + provider_args: None, + provider_metadata: None, + }) + }) + .collect::, _>>()?; + let (tool_choice, tool_choice_name) = match input.tool_choice { + None => (ToolChoiceMode::Unspecified, String::new()), + Some(Value::String(value)) => ( + match value.as_str() { + "auto" => ToolChoiceMode::Auto, + "none" => ToolChoiceMode::None, + "required" => ToolChoiceMode::Required, + _ => return Err(invalid_payload()), + }, + String::new(), + ), + Some(value) => { + let choice: NamedChoice = + serde_json::from_value(value).map_err(|_| invalid_payload())?; + if choice.kind != "tool" { + return Err(invalid_payload()); + } + identifier(&choice.name)?; + (ToolChoiceMode::Tool, choice.name) + } + }; + let response_format = input + .response_format + .map(|value| { + let format: StructuredFormat = + serde_json::from_value(value).map_err(|_| invalid_payload())?; + let kind = match format.kind.as_str() { + "text" => ResponseFormatKind::Text, + "json-object" => ResponseFormatKind::JsonObject, + "json-schema" => ResponseFormatKind::JsonSchema, + _ => return Err(invalid_payload()), + }; + if (kind == ResponseFormatKind::JsonSchema && format.schema.is_none()) + || (kind != ResponseFormatKind::JsonSchema + && (format.schema.is_some() || format.strict)) + { + return Err(invalid_payload()); + } + Ok(ResponseFormat { + kind: kind as i32, + json_schema: format.schema.map(proto_struct).transpose()?, + schema_name: format.name, + schema_description: format.description, + strict: format.strict, + }) + }) + .transpose()?; + Ok(StreamLocalAppTextTurnRequest { + messages, + tools, + tool_choice: tool_choice as i32, + tool_choice_name, + response_format, + temperature: input.temperature, + top_p: input.top_p, + max_tokens: input.max_tokens, + top_k: input.top_k, + presence_penalty: input.presence_penalty, + frequency_penalty: input.frequency_penalty, + stop: input.stop, + seed: input.seed, + }) +} + +fn parse_turn_item(value: Value) -> Result { + let item: TurnItem = serde_json::from_value(value).map_err(|_| invalid_payload())?; + let item = match item { + TurnItem::Output { output } => text_turn_item::Item::Output(TextOutputItem { + item: Some(match output { + OutputItem::ReasoningContinuity { carrier } => { + identifier(&carrier.kind)?; + if carrier.version == 0 || carrier.payload.is_empty() || carrier.payload.len() > MAX_CONTINUITY_BYTES { + return Err(invalid_payload()); + } + text_output_item::Item::ReasoningContinuity(ReasoningContinuityCarrier { + kind: carrier.kind, version: carrier.version, payload: carrier.payload, + }) + } + OutputItem::Text { text } => { + if text.is_empty() { + return Err(invalid_payload()); + } + text_output_item::Item::Text(TextOutputText { text }) + } + OutputItem::ToolCall { tool_call } => { + identifier(&tool_call.id)?; + identifier(&tool_call.name)?; + if !tool_call.arguments.is_object() { + return Err(invalid_payload()); + } + text_output_item::Item::ToolCall(ToolCall { + id: tool_call.id, + name: tool_call.name, + arguments_json: serde_json::to_string(&tool_call.arguments) + .map_err(|_| invalid_payload())?, + dynamic: false, + provider_metadata: None, + }) + } + }), + }), + TurnItem::ToolResult { tool_result } => { + identifier(&tool_result.tool_call_id)?; + identifier(&tool_result.tool_name)?; + text_turn_item::Item::ToolResult(ToolResult { + tool_call_id: tool_result.tool_call_id, + tool_name: tool_result.tool_name, + result: Some(proto_value(tool_result.result)?), + is_error: tool_result.is_error, + preliminary: false, + dynamic: false, + provider_metadata: None, + }) + } + }; + Ok(TextTurnItem { item: Some(item) }) +} + +fn identifier(value: &str) -> Result<(), LocalAppOperationError> { + if value.is_empty() + || value.len() > 128 + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(invalid_payload()); + } + Ok(()) +} + +fn proto_struct(value: Value) -> Result { + let Value::Object(object) = value else { + return Err(invalid_payload()); + }; + let fields = object + .into_iter() + .map(|(key, value)| Ok((key, proto_value(value)?))) + .collect::, _>>()?; + Ok(Struct { fields }) +} + +fn proto_value(value: Value) -> Result { + let kind = match value { + Value::Null => Kind::NullValue(0), + Value::Bool(value) => Kind::BoolValue(value), + Value::Number(value) => Kind::NumberValue( + value + .as_f64() + .filter(|v| v.is_finite()) + .ok_or_else(invalid_payload)?, + ), + Value::String(value) => Kind::StringValue(value), + Value::Array(values) => Kind::ListValue(ListValue { + values: values + .into_iter() + .map(proto_value) + .collect::>()?, + }), + value @ Value::Object(_) => Kind::StructValue(proto_struct(value)?), + }; + Ok(ProtoValue { kind: Some(kind) }) +} + +pub(super) fn project_tool_call(call: ToolCall) -> Result { + identifier(&call.id).map_err(|_| untrusted())?; + identifier(&call.name).map_err(|_| untrusted())?; + if call.dynamic || call.provider_metadata.is_some() { + return Err(untrusted()); + } + let arguments: Value = serde_json::from_str(&call.arguments_json).map_err(|_| untrusted())?; + if !arguments.is_object() { + return Err(untrusted()); + } + Ok(json!({"id": call.id, "name": call.name, "arguments": arguments})) +} + +pub(super) fn project_continuity(carrier: ReasoningContinuityCarrier) -> Result { + identifier(&carrier.kind).map_err(|_| untrusted())?; + if carrier.version == 0 || carrier.payload.is_empty() || carrier.payload.len() > MAX_CONTINUITY_BYTES { + return Err(untrusted()); + } + Ok(json!({"kind": carrier.kind, "version": carrier.version, "payload": carrier.payload})) +} + +pub(super) fn finish_reason(reason: i32) -> Result<&'static str, LocalAppOperationError> { + match reason { + 1 => Ok("stop"), + 2 => Ok("length"), + 3 => Ok("tool-calls"), + 4 => Ok("content-filter"), + _ => Err(untrusted()), + } +} + +pub(super) fn project_output( + output: LocalAppTextGenerateOutput, +) -> Result { + let finish = finish_reason(output.finish_reason)?; + let mut has_call = false; + let mut has_text = false; + let mut content_bytes = 0; + let items = output + .items + .into_iter() + .map(|item| match item.item { + Some(text_output_item::Item::Text(value)) if !value.text.is_empty() => { + has_text = true; + content_bytes += value.text.len(); + Ok(json!({"type": "text", "text": value.text})) + } + Some(text_output_item::Item::ToolCall(call)) => { + has_call = true; + content_bytes += prost::Message::encoded_len(&call); + Ok(json!({"type": "tool-call", "toolCall": project_tool_call(call)?})) + } + Some(text_output_item::Item::ReasoningContinuity(carrier)) => { + content_bytes += prost::Message::encoded_len(&carrier); + Ok(json!({"type": "reasoning-continuity", "carrier": project_continuity(carrier)?})) + } + _ => Err(untrusted()), + }) + .collect::, _>>()?; + if !has_text && !has_call + || (finish == "tool-calls" && !has_call) + || content_bytes > MAX_OUTPUT_BYTES + { + return Err(untrusted()); + } + Ok(json!({"type": "text-generate", "items": items, "finishReason": finish})) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_output_budget_counts_content_before_json_expansion() { + let carrier = ReasoningContinuityCarrier { + kind: "openai_codex.responses.encrypted-reasoning".into(), + version: 1, + payload: serde_json::to_vec(&json!({ + "type": "reasoning", "id": "rs_budget", "encrypted_content": "A".repeat(60 * 1024), "summary": [] + })).unwrap(), + }; + let call = ToolCall { + id: "call-budget".into(), name: "search".into(), + arguments_json: format!("{{\"values\":[{}]}}", vec!["1e20"; 16 * 1024].join(",")), + ..Default::default() + }; + let remaining = MAX_OUTPUT_BYTES - prost::Message::encoded_len(&carrier) - prost::Message::encoded_len(&call); + for text_bytes in [100 * 1024, remaining, remaining + 1] { + let output = LocalAppTextGenerateOutput { + items: vec![ + TextOutputItem { item: Some(text_output_item::Item::ReasoningContinuity(carrier.clone())) }, + TextOutputItem { item: Some(text_output_item::Item::ToolCall(call.clone())) }, + TextOutputItem { item: Some(text_output_item::Item::Text(TextOutputText { text: "x".repeat(text_bytes) })) }, + ], + finish_reason: 3, + }; + let projected = project_output(output); + assert_eq!(projected.is_ok(), text_bytes <= remaining); + if let Ok(projected) = projected { + assert_eq!(projected["items"][1]["toolCall"]["arguments"]["values"][0], json!(1e20)); + } + } + } + + #[test] + fn bounded_continuity_round_trips_without_becoming_primary_output() { + let carrier = json!({"kind":"test.encrypted", "version":1, "payload":[0,255]}); + let input = json!({"type":"output", "output":{"type":"reasoning-continuity", "carrier":carrier}}); + let parsed = parse_turn_item(input).unwrap(); + let Some(text_turn_item::Item::Output(item)) = parsed.item else { panic!("missing output") }; + assert!(project_output(LocalAppTextGenerateOutput { items: vec![item.clone()], finish_reason: 1 }).is_err()); + let output = project_output(LocalAppTextGenerateOutput { items: vec![item, TextOutputItem { item: Some(text_output_item::Item::Text(TextOutputText { text: "Answer".into() })) }], finish_reason: 1 }).unwrap(); + assert_eq!(output["items"][0]["carrier"], carrier); + assert!(parse_turn_item(json!({"type":"output", "output":{"type":"reasoning-continuity", "carrier":{"kind":"test", "version":1, "payload":[256]}}})).is_err()); + } + + #[test] + fn function_transcript_and_response_schema_cross_the_typed_boundary() { + let input: LocalAppTextTurnRequest = serde_json::from_value(json!({ + "messages": [ + {"role": "user", "text": "search"}, + {"role": "assistant", "turnItems": [ + {"type": "output", "output": {"type": "tool-call", "toolCall": {"id": "c1", "name": "search", "arguments": {"q": "Nimi"}}}}, + {"type": "tool-result", "toolResult": {"toolCallId": "c1", "toolName": "search", "result": {"items": []}, "isError": false}} + ]} + ], + "tools": [{"name": "search", "inputSchema": {"type": "object"}}], + "toolChoice": "none", + "responseFormat": {"type": "json-schema", "schema": {"type": "object"}, "strict": true} + })).unwrap(); + let out = request(input).unwrap(); + assert_eq!(out.messages[1].turn_items.len(), 2); + assert_eq!(out.tools[0].kind, ToolSpecKind::Function as i32); + assert_eq!(out.tool_choice, ToolChoiceMode::None as i32); + assert!(out.response_format.unwrap().strict); + } + + #[test] + fn authority_and_provider_fields_are_not_accepted() { + assert!(serde_json::from_value::( + json!({"messages": [{"role": "user", "text": "hi"}], "provider": "bypass"}) + ) + .is_err()); + let input = serde_json::from_value(json!({"messages": [{"role": "user", "text": "hi"}], "tools": [{"name": "x", "inputSchema": {}, "providerMetadata": {}}]})).unwrap(); + assert!(request(input).is_err()); + } +} diff --git a/kit/shell/protected-local/src/windows_local_app/text_candidate.rs b/kit/shell/protected-local/src/windows_local_app/text_candidate.rs index 85d74ac64b..d853a4d566 100644 --- a/kit/shell/protected-local/src/windows_local_app/text_candidate.rs +++ b/kit/shell/protected-local/src/windows_local_app/text_candidate.rs @@ -26,6 +26,7 @@ pub(super) async fn generate( .map(|message| ProtoMessage { role: message.role, text: message.text, + turn_items: vec![], }) .collect(), temperature: request.temperature, diff --git a/kit/shell/renderer/src/bridge/local-app.ts b/kit/shell/renderer/src/bridge/local-app.ts index ecd16eed81..998454925a 100644 --- a/kit/shell/renderer/src/bridge/local-app.ts +++ b/kit/shell/renderer/src/bridge/local-app.ts @@ -19,8 +19,11 @@ import type { NimiLocalAppExecutionInterruption, NimiLocalAppVisionLocateResult, NimiLocalAppVisionLocation, + NimiLocalAppTextTurnInput as SdkLocalAppTextTurnInput, + NimiLocalAppTextTurnEvent as SdkLocalAppTextTurnEvent, + NimiLocalAppTextOutputItem, } from '@nimiplatform/kit/core/sdk-contract'; -import { runtimeAIConfigStructToJson } from '@nimiplatform/kit/core/sdk-contract'; +import { runtimeAIConfigStructToJson, validateNimiLocalAppTextInput, validateNimiLocalAppTextOutputItems } from '@nimiplatform/kit/core/sdk-contract'; import { BridgeError, invoke, invokeChecked } from './invoke.js'; import { listenShell } from './tauri-api.js'; import { assertRecord, parseRequiredString } from './types.js'; @@ -113,13 +116,7 @@ export type NimiLocalAppTextCandidateInput = { readonly maxTokens?: number; }; -export type NimiLocalAppTextTurnInput = NimiLocalAppTextCandidateInput & { - readonly topK?: number; - readonly presencePenalty?: number; - readonly frequencyPenalty?: number; - readonly stop?: readonly string[]; - readonly seed?: number; -}; +export type NimiLocalAppTextTurnInput = SdkLocalAppTextTurnInput; export type NimiLocalAppTextCandidateResult = { readonly text: string; @@ -128,6 +125,7 @@ export type NimiLocalAppTextCandidateResult = { }; export type NimiLocalAppScenarioExecuteSpec = + | ({ readonly type: 'text-generate' } & NimiLocalAppTextTurnInput) | { readonly type: 'text-embed'; readonly inputs: readonly string[] } | NimiLocalAppImageGenerateSpec; @@ -232,6 +230,7 @@ export type NimiLocalAppVoiceAsset = { readonly expiresAt: NimiLocalAppScenarioTimestamp | null; }; export type NimiLocalAppScenarioExecuteResult = + | { readonly output: { readonly type: 'text-generate'; readonly items: readonly NimiLocalAppTextOutputItem[]; readonly finishReason: 'stop' | 'length' | 'tool-calls' | 'content-filter' }; readonly traceId: string } | { readonly output: { readonly type: 'text-embed'; readonly vectors: readonly (readonly number[])[] }; readonly traceId: string } | { readonly output: { readonly type: 'image-generate'; readonly artifacts: readonly NimiLocalAppScenarioArtifact[] }; readonly traceId: string }; export type NimiLocalAppScenarioJobSubmitResult = { @@ -248,10 +247,7 @@ export type NimiLocalAppArtifactUploadResult = { readonly sizeBytes: number; readonly mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' | 'video/mp4'; }; -export type NimiLocalAppTextTurnEvent = - | { readonly type: 'delta'; readonly sequence: string; readonly traceId: string; readonly text: string } - | { readonly type: 'completed'; readonly sequence: string; readonly traceId: string; readonly finishReason: 'stop' | 'length' | 'content-filter' } - | { readonly type: 'failed'; readonly sequence: string; readonly traceId: string; readonly reasonCode: string; readonly actionHint: string; readonly interruption: { readonly cause: 'runtime-restart'; readonly resubmitDisposition: 'caller-may-resubmit' } | null }; +export type NimiLocalAppTextTurnEvent = SdkLocalAppTextTurnEvent; export type NimiLocalAppScenarioJobEvent = { readonly eventType: 'submitted' | 'queued' | 'running' | 'completed' | 'failed' | 'canceled' | 'timeout'; readonly sequence: string; readonly traceId: string; readonly timestamp: NimiLocalAppScenarioTimestamp | null; @@ -710,23 +706,12 @@ function canonicalTextTurnInput( input: NimiLocalAppTextTurnInput, command: string, ): JsonObject { - const output = canonicalTextInputBase( - input, - command, - ['messages', 'temperature', 'topP', 'maxTokens', 'topK', 'presencePenalty', 'frequencyPenalty', 'stop', 'seed'], - ); - if (input.topK !== undefined) output.topK = boundedSafeInteger(input.topK, 'topK', command, 0, 2_147_483_647); - if (input.presencePenalty !== undefined) output.presencePenalty = boundedFiniteNumber(input.presencePenalty, 'presencePenalty', command, -2, 2); - if (input.frequencyPenalty !== undefined) output.frequencyPenalty = boundedFiniteNumber(input.frequencyPenalty, 'frequencyPenalty', command, -2, 2); - if (input.stop !== undefined) { - if (!Array.isArray(input.stop) - || input.stop.some((value) => typeof value !== 'string' || value.trim() === '')) { - throw invalidInput(command, 'stop is invalid'); - } - output.stop = [...input.stop]; + // @nimi-authority: rule.nimi.sdks.feature-clients.local-app-text-behaviors + try { + return validateNimiLocalAppTextInput(input) as unknown as JsonObject; + } catch { + throw invalidInput(command, 'text input is invalid'); } - if (input.seed !== undefined) output.seed = boundedSafeInteger(input.seed, 'seed', command, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); - return output; } function canonicalTextInputBase( @@ -791,8 +776,9 @@ export async function streamNimiLocalAppTextTurn( const event = parseTextTurnEvent(value, command); if (event.type === 'delta' && typeof event.text === 'string') { deltaBytes += new TextEncoder().encode(event.text).byteLength; - if (deltaBytes > MAX_TEXT_CANDIDATE_RESULT_BYTES) throw new Error(`${command}: text-turn output is too large`); } + if (event.type === 'tool-call') deltaBytes += new TextEncoder().encode(JSON.stringify(event.toolCall)).byteLength; + if (deltaBytes > MAX_TEXT_CANDIDATE_RESULT_BYTES) throw new Error(`${command}: text-turn output is too large`); return event as NimiLocalAppTextTurnEvent; }, ); @@ -883,8 +869,12 @@ export function listNimiLocalAppVoiceAssets( (value) => parseVoiceAssetsList(value, command)); } -function canonicalScenarioSpec(spec: Readonly, command: string): JsonObject { +function canonicalScenarioSpec(spec: unknown, command: string): JsonObject { const record = assertRecord(spec, `${command}: scenario spec must be an object`); + if (record.type === 'text-generate' && command === AIC_COMMANDS.scenarioExecute) { + const { type, ...input } = record; + return { type, ...canonicalTextTurnInput(input as unknown as NimiLocalAppTextTurnInput, command) }; + } validateProjectionValue(record as JsonValue, command); const encoded = JSON.stringify(record); if (new TextEncoder().encode(encoded).byteLength > 40 * 1024 * 1024) { @@ -2172,7 +2162,11 @@ function parseScenarioExecute(value: unknown, command: string): NimiLocalAppScen assertProjectionKeys(record, ['output', 'traceId'], command, 'scenario execute'); requiredText(record.traceId, 'traceId', command, 512); const output = assertRecord(record.output, `${command}: execute output is invalid`); - if (output.type === 'text-embed') { + if (output.type === 'text-generate') { + assertProjectionKeys(output, ['type', 'items', 'finishReason'], command, 'text output'); + validateNimiLocalAppTextOutputItems(output.items); + if (!['stop', 'length', 'tool-calls', 'content-filter'].includes(String(output.finishReason))) throw new Error(`${command}: finishReason is invalid`); + } else if (output.type === 'text-embed') { assertProjectionKeys(output, ['type', 'vectors'], command, 'embed output'); if (!Array.isArray(output.vectors) || output.vectors.length === 0 || output.vectors.length > 16 || output.vectors.some((vector) => !Array.isArray(vector) || vector.length === 0 || vector.length > 8192 @@ -2411,20 +2405,29 @@ function parseTextTurnEvent(value: unknown, command: string): NimiLocalAppTextTu } const traceId = requiredText(record.traceId, 'traceId', command, 512); if (record.type === 'delta') { - assertProjectionKeys(record, ['type', 'sequence', 'traceId', 'text'], command, 'text delta'); + assertProjectionKeys(record, ['type', 'sequence', 'traceId', 'text', 'itemIndex'], command, 'text delta'); return Object.freeze({ type: 'delta', sequence: record.sequence, traceId, + itemIndex: boundedSafeInteger(record.itemIndex, 'itemIndex', command, 0, 4_294_967_295), text: requiredUtf8Content(record.text, 'text', command, 64 * 1024) }); } + if (record.type === 'tool-call') { + assertProjectionKeys(record, ['type', 'sequence', 'traceId', 'itemIndex', 'toolCall'], command, 'text tool call'); + const item = validateNimiLocalAppTextOutputItems([{ type: 'tool-call', toolCall: record.toolCall }])[0]; + if (item?.type !== 'tool-call') throw new Error(`${command}: tool call is invalid`); + return Object.freeze({ type: 'tool-call', sequence: record.sequence, traceId, + itemIndex: boundedSafeInteger(record.itemIndex, 'itemIndex', command, 0, 4_294_967_295), toolCall: item.toolCall }); + } if (record.type === 'completed') { assertProjectionKeys(record, ['type', 'sequence', 'traceId', 'finishReason'], command, 'text completion'); - if (!['stop', 'length', 'content-filter'].includes(String(record.finishReason))) throw new Error(`${command}: finishReason is invalid`); + if (!['stop', 'length', 'tool-calls', 'content-filter'].includes(String(record.finishReason))) throw new Error(`${command}: finishReason is invalid`); return Object.freeze({ ...record }) as unknown as NimiLocalAppTextTurnEvent; } if (record.type === 'failed') { - assertProjectionKeys(record, ['type', 'sequence', 'traceId', 'reasonCode', 'actionHint', 'interruption'], command, 'text failure'); + assertProjectionKeys(record, ['type', 'sequence', 'traceId', 'reasonCode', 'actionHint', ...(Object.hasOwn(record, 'interruption') ? ['interruption'] : [])], command, 'text failure'); requiredText(record.reasonCode, 'reasonCode', command, 128); optionalProjectionText(record.actionHint, 512, command); - if (record.interruption !== null) { + if ((record.interruption != null) !== (record.reasonCode === 'ai-execution-interrupted')) throw new Error(`${command}: text interruption is invalid`); + if (record.interruption != null) { const interruption = assertRecord(record.interruption, `${command}: text interruption is invalid`); assertProjectionKeys(interruption, ['cause', 'resubmitDisposition'], command, 'text interruption'); if (interruption.cause !== 'runtime-restart' || interruption.resubmitDisposition !== 'caller-may-resubmit') { diff --git a/kit/shell/renderer/test/local-app-bridge.test.ts b/kit/shell/renderer/test/local-app-bridge.test.ts index 8a129fbe2c..b87af03ab7 100644 --- a/kit/shell/renderer/test/local-app-bridge.test.ts +++ b/kit/shell/renderer/test/local-app-bridge.test.ts @@ -37,6 +37,50 @@ function managerActionAvailability() { } describe('renderer local-app standard-shell surface', () => { + it('round trips tool declarations, ordered output and typed termination through the public App client', async () => { + let emit: ((event: { payload: unknown }) => void) | undefined; + const requests: unknown[] = []; + let unlistenCalls = 0; + const call = { id: 'call-1', name: 'search', arguments: { subject: 'trees', token: 'business data' } }; + const output = { type: 'text-generate', items: [{ type: 'text', text: 'Looking up sources.' }, { type: 'tool-call', toolCall: call }], finishReason: 'tool-calls' }; + (globalThis as { __NIMI_ELECTRON_TEST__?: unknown }).__NIMI_ELECTRON_TEST__ = { + invoke: async (command: string, input: { payload: { action?: string } }) => { + requests.push([command, input.payload]); + if (command === NIMI_STANDARD_SHELL_COMMANDS['local-app.scenarioExecute']) return { output, traceId: 'trace-1' }; + return input.payload.action === 'cancel' + ? { subscriptionId: 'text-1', closed: true } + : { subscriptionId: 'text-1', eventName: 'text-events-1' }; + }, + listen: (_name: string, listener: typeof emit) => { emit = listener; return () => { unlistenCalls++; }; }, + }; + const client = createNimiClient({ localApp: { standardShell: createNimiLocalAppStandardShellSurface() } }); + const input = { + messages: [{ role: 'user' as const, text: 'Find sources.' }], + tools: [{ type: 'function' as const, name: 'search', inputSchema: { type: 'object', properties: { subject: { type: 'string' } } } }], + toolChoice: 'required' as const, + }; + await expect(client.ai.scenario.execute({ type: 'text-generate', ...input })).resolves.toEqual({ output, traceId: 'trace-1' }); + const stream = await client.ai.text.streamTurn(input); + const iterator = stream[Symbol.asyncIterator](); + for (const event of [ + { type: 'delta', sequence: '1', traceId: 'trace-1', itemIndex: 0, text: 'Looking up sources.' }, + { type: 'tool-call', sequence: '2', traceId: 'trace-1', itemIndex: 1, toolCall: call }, + { type: 'completed', sequence: '3', traceId: 'trace-1', finishReason: 'tool-calls' }, + ]) { + const next = iterator.next(); + emit!({ payload: { subscriptionId: 'text-1', eventType: 'next', event } }); + await expect(next).resolves.toEqual({ done: false, value: event }); + } + emit!({ payload: { subscriptionId: 'text-1', eventType: 'completed' } }); + await expect(iterator.next()).resolves.toMatchObject({ done: true }); + expect(requests.slice(0, 2)).toEqual([ + [NIMI_STANDARD_SHELL_COMMANDS['local-app.scenarioExecute'], { spec: { type: 'text-generate', ...input } }], + [NIMI_STANDARD_SHELL_COMMANDS['local-app.textTurnStream'], input], + ]); + expect(requests).toHaveLength(2); + expect(unlistenCalls).toBe(1); + }); + it.each([false, true])('validates Realm event envelopes without losing their session fields (extra field: %s)', async (extraField) => { let emit: ((event: { payload: unknown }) => void) | undefined; (globalThis as { __NIMI_ELECTRON_TEST__?: unknown }).__NIMI_ELECTRON_TEST__ = { diff --git a/kit/shell/tauri/Cargo.lock b/kit/shell/tauri/Cargo.lock index e99d7a012b..6a8daf05ac 100644 --- a/kit/shell/tauri/Cargo.lock +++ b/kit/shell/tauri/Cargo.lock @@ -760,7 +760,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -973,7 +973,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2320,7 +2320,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nimi-shell-protected-local" -version = "0.3.0" +version = "0.4.0" dependencies = [ "base64 0.22.1", "cc", @@ -2349,7 +2349,7 @@ dependencies = [ [[package]] name = "nimi-shell-tauri" -version = "0.3.0" +version = "0.4.0" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -3007,7 +3007,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools", "log", "multimap", @@ -3201,7 +3201,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3482,7 +3482,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4403,10 +4403,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5431,7 +5431,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/kit/shell/tauri/Cargo.toml b/kit/shell/tauri/Cargo.toml index c63e80e998..ff9e4368c5 100644 --- a/kit/shell/tauri/Cargo.toml +++ b/kit/shell/tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nimi-shell-tauri" -version = "0.3.0" +version = "0.4.0" edition = "2021" description = "Shared Tauri host glue for Nimi apps — runtime bridge, daemon lifecycle, session logging, auth/oauth commands, runtime defaults." license = "MIT" @@ -29,7 +29,7 @@ log = "0.4" chrono = { version = "0.4", features = ["serde"] } image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] } zip = { version = "4.6.1", default-features = false, features = ["deflate"] } -nimi-shell-protected-local = { version = "0.3.0", path = "../protected-local" } +nimi-shell-protected-local = { version = "0.4.0", path = "../protected-local" } [build-dependencies] tonic-prost-build = "0.14" diff --git a/kit/shell/tauri/src/runtime_bridge/generated/nimi.runtime.v1.rs b/kit/shell/tauri/src/runtime_bridge/generated/nimi.runtime.v1.rs index ee54598e3d..2688b6fe20 100644 --- a/kit/shell/tauri/src/runtime_bridge/generated/nimi.runtime.v1.rs +++ b/kit/shell/tauri/src/runtime_bridge/generated/nimi.runtime.v1.rs @@ -5617,6 +5617,10 @@ pub struct EmbeddingVector { pub struct TextEmbedOutput { #[prost(message, repeated, tag = "1")] pub vectors: ::prost::alloc::vec::Vec, + /// Opaque compatibility identity of the captured embedding semantics. + /// Compare actual result values before combining vectors or reusing an index. + #[prost(string, tag = "2")] + pub space_id: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct ImageGenerateResult { @@ -5744,12 +5748,16 @@ pub struct ExecuteScenarioResponse { /// Exact third-party Local App foreground text-candidate contract. Runtime /// derives account, App identity, permission and managed local route from the /// protected session; callers cannot supply generic Scenario or route fields. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct LocalAppTextCandidateMessage { #[prost(string, tag = "1")] pub role: ::prost::alloc::string::String, #[prost(string, tag = "2")] pub text: ::prost::alloc::string::String, + /// Ordered assistant transcript is admitted only by text-turn and the + /// text.generate execute variant. The narrow candidate operation rejects it. + #[prost(message, repeated, tag = "3")] + pub turn_items: ::prost::alloc::vec::Vec, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct GenerateLocalAppTextCandidateRequest { @@ -5840,7 +5848,7 @@ pub struct LocalAppImageGenerateScenarioSpec { } #[derive(Clone, PartialEq, ::prost::Message)] pub struct ExecuteLocalAppScenarioRequest { - #[prost(oneof = "execute_local_app_scenario_request::Spec", tags = "1, 2")] + #[prost(oneof = "execute_local_app_scenario_request::Spec", tags = "1, 2, 3")] pub spec: ::core::option::Option, } /// Nested message and enum types in `ExecuteLocalAppScenarioRequest`. @@ -5851,12 +5859,17 @@ pub mod execute_local_app_scenario_request { TextEmbed(super::LocalAppTextEmbedScenarioSpec), #[prost(message, tag = "2")] ImageGenerate(super::LocalAppImageGenerateScenarioSpec), + /// Reuse the single text-turn input for synchronous text generation. + #[prost(message, tag = "3")] + TextGenerate(super::StreamLocalAppTextTurnRequest), } } #[derive(Clone, PartialEq, ::prost::Message)] pub struct LocalAppTextEmbedOutput { #[prost(message, repeated, tag = "1")] pub vectors: ::prost::alloc::vec::Vec, + #[prost(string, tag = "2")] + pub space_id: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct LocalAppImageGenerateOutput { @@ -5864,10 +5877,18 @@ pub struct LocalAppImageGenerateOutput { pub artifacts: ::prost::alloc::vec::Vec, } #[derive(Clone, PartialEq, ::prost::Message)] +pub struct LocalAppTextGenerateOutput { + /// Only text and function ToolCall items are admitted at this App boundary. + #[prost(message, repeated, tag = "1")] + pub items: ::prost::alloc::vec::Vec, + #[prost(enumeration = "FinishReason", tag = "2")] + pub finish_reason: i32, +} +#[derive(Clone, PartialEq, ::prost::Message)] pub struct ExecuteLocalAppScenarioResponse { #[prost(string, tag = "3")] pub trace_id: ::prost::alloc::string::String, - #[prost(oneof = "execute_local_app_scenario_response::Output", tags = "1, 2")] + #[prost(oneof = "execute_local_app_scenario_response::Output", tags = "1, 2, 4")] pub output: ::core::option::Option, } /// Nested message and enum types in `ExecuteLocalAppScenarioResponse`. @@ -5878,6 +5899,8 @@ pub mod execute_local_app_scenario_response { TextEmbed(super::LocalAppTextEmbedOutput), #[prost(message, tag = "2")] ImageGenerate(super::LocalAppImageGenerateOutput), + #[prost(message, tag = "4")] + TextGenerate(super::LocalAppTextGenerateOutput), } } /// Asynchronous closed-set Job specs admitted for SubmitLocalAppScenarioJob. @@ -6152,9 +6175,9 @@ pub struct LocalAppScenarioJobEvent { #[prost(message, optional, tag = "5")] pub job: ::core::option::Option, } -/// Streaming TEXT_GENERATE trimmed surface: typed text increments and terminal -/// finish or failure state only. Raw chunks, reasoning traces, sources, and -/// tool events never reach the Local App stream. +/// Single-step TEXT_GENERATE App surface. Function tools, ordered host-provided +/// results and structured output reuse canonical text behavior. The external +/// AI host owns tool execution and the next request; Runtime owns no agent loop. #[derive(Clone, PartialEq, ::prost::Message)] pub struct StreamLocalAppTextTurnRequest { #[prost(message, repeated, tag = "1")] @@ -6175,11 +6198,36 @@ pub struct StreamLocalAppTextTurnRequest { pub stop: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, #[prost(int64, optional, tag = "9")] pub seed: ::core::option::Option, + #[prost(message, repeated, tag = "10")] + pub tools: ::prost::alloc::vec::Vec, + #[prost(enumeration = "ToolChoiceMode", tag = "11")] + pub tool_choice: i32, + #[prost(string, tag = "12")] + pub tool_choice_name: ::prost::alloc::string::String, + #[prost(message, optional, tag = "13")] + pub response_format: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct LocalAppTextTurnDelta { #[prost(string, tag = "1")] pub text: ::prost::alloc::string::String, + #[prost(uint32, tag = "2")] + pub item_index: u32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LocalAppTextTurnToolCall { + #[prost(uint32, tag = "1")] + pub item_index: u32, + #[prost(message, optional, tag = "2")] + pub tool_call: ::core::option::Option, +} +/// Opaque transcript continuity, never raw reasoning or a reasoning control. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct LocalAppTextTurnContinuity { + #[prost(uint32, tag = "1")] + pub item_index: u32, + #[prost(message, optional, tag = "2")] + pub carrier: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct LocalAppTextTurnCompleted { @@ -6195,18 +6243,18 @@ pub struct LocalAppTextTurnFailed { #[prost(message, optional, tag = "3")] pub interruption: ::core::option::Option, } -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct StreamLocalAppTextTurnEvent { #[prost(uint64, tag = "1")] pub sequence: u64, #[prost(string, tag = "2")] pub trace_id: ::prost::alloc::string::String, - #[prost(oneof = "stream_local_app_text_turn_event::Payload", tags = "3, 4, 5")] + #[prost(oneof = "stream_local_app_text_turn_event::Payload", tags = "3, 4, 5, 6, 7")] pub payload: ::core::option::Option, } /// Nested message and enum types in `StreamLocalAppTextTurnEvent`. pub mod stream_local_app_text_turn_event { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + #[derive(Clone, PartialEq, ::prost::Oneof)] pub enum Payload { #[prost(message, tag = "3")] Delta(super::LocalAppTextTurnDelta), @@ -6214,6 +6262,10 @@ pub mod stream_local_app_text_turn_event { Completed(super::LocalAppTextTurnCompleted), #[prost(message, tag = "5")] Failed(super::LocalAppTextTurnFailed), + #[prost(message, tag = "6")] + ToolCall(super::LocalAppTextTurnToolCall), + #[prost(message, tag = "7")] + ReasoningContinuity(super::LocalAppTextTurnContinuity), } } /// Bounded inline artifact read limited to artifacts owned by the calling App diff --git a/kit/ui/AGENTS.md b/kit/ui/AGENTS.md index b2399dd76d..1974df59b5 100644 --- a/kit/ui/AGENTS.md +++ b/kit/ui/AGENTS.md @@ -20,4 +20,4 @@ ## Verification Commands - Component-only work: run the affected Kit test and `pnpm --filter @nimiplatform/kit build`. - Token/theme/primitive-table work: `pnpm generate:nimi-ui-lib`, `pnpm check:nimi-ui-pattern`, and `pnpm check:nimi-ui-lib-drift`. -- Run `pnpm check:nimi-kit` for Kit-wide changes; run spec governance only when canonical authority changes. +- Run `pnpm check:nimi-kit` for the public-export, boundary, shared-projection, or Kit-wide changes defined by `kit/AGENTS.md`; run spec governance only when canonical authority changes. diff --git a/package.json b/package.json index d8ab0bd55c..9746583916 100644 --- a/package.json +++ b/package.json @@ -191,7 +191,7 @@ "yaml": "^2.9.0" }, "devDependencies": { - "@nimiplatform/nimi-coding": "0.6.2", + "@nimiplatform/nimi-coding": "0.6.3", "@protobuf-ts/plugin": "2.11.1", "email-validator": "2.0.4", "resedit": "3.1.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdf9c355ce..a864ad14b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: version: 2.9.0 devDependencies: '@nimiplatform/nimi-coding': - specifier: 0.6.2 - version: 0.6.2 + specifier: 0.6.3 + version: 0.6.3 '@protobuf-ts/plugin': specifier: 2.11.1 version: 2.11.1 @@ -357,8 +357,8 @@ importers: specifier: workspace:* version: link:../../app-tools '@nimiplatform/nimi-coding': - specifier: 0.6.2 - version: 0.6.2 + specifier: 0.6.3 + version: 0.6.3 '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)) @@ -716,7 +716,7 @@ importers: specifier: ^1.14.4 version: 1.14.4 '@nimiplatform/sdk': - specifier: workspace:^0.11.0 + specifier: workspace:^0.12.0 version: link:../sdks/typescript '@radix-ui/react-avatar': specifier: ^1.2.6 @@ -2094,8 +2094,8 @@ packages: os: [linux] libc: [glibc] - '@nimiplatform/nimi-coding@0.6.2': - resolution: {integrity: sha512-8mFwjcF0tYJB3MeOlDdL7ZNfP6CixW8wJq0OircmnZGdhk7aZeMCZU5bpRGj2v2Q/b1Xqw4hy0qY+74vy1LcAQ==} + '@nimiplatform/nimi-coding@0.6.3': + resolution: {integrity: sha512-glJNhBHPX094sukkl4DN+W9mm6En+vA6dN//BCEOHSiJ+gkBjNf0PriGbS3bqa6uliSKDy1lJ9NQYg2iN2okCA==} engines: {node: '>=24.0.0', pnpm: '>=10.0.0'} hasBin: true @@ -7069,7 +7069,7 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@nimiplatform/nimi-coding@0.6.2': + '@nimiplatform/nimi-coding@0.6.3': dependencies: typescript: 5.9.3 yaml: 2.9.0 diff --git a/proto/runtime/v1/ai.proto b/proto/runtime/v1/ai.proto index b332271238..bd2e1ea6d6 100644 --- a/proto/runtime/v1/ai.proto +++ b/proto/runtime/v1/ai.proto @@ -692,6 +692,9 @@ message EmbeddingVector { message TextEmbedOutput { repeated EmbeddingVector vectors = 1; + // Opaque compatibility identity of the captured embedding semantics. + // Compare actual result values before combining vectors or reusing an index. + string space_id = 2; } message ImageGenerateResult { @@ -779,6 +782,9 @@ message ExecuteScenarioResponse { message LocalAppTextCandidateMessage { string role = 1; string text = 2; + // Ordered assistant transcript is admitted only by text-turn and the + // text.generate execute variant. The narrow candidate operation rejects it. + repeated TextTurnItem turn_items = 3; } message GenerateLocalAppTextCandidateRequest { @@ -799,8 +805,8 @@ message GenerateLocalAppTextCandidateResponse { // Exact third-party Local App scenario-consumption contracts. Runtime derives // account, App identity, App Access and managed route composition from the // protected session and the calling App owner's committed AIConfig; callers -// cannot supply generic Scenario head, route, provider, model, target, or tool -// fields. +// cannot supply generic Scenario head, route, provider, model, or target fields. +// Function tools and structured output are admitted only by the text variant. // Trimmed artifact projection for Local App scenario outputs. Runtime-private // uri, producer, and owner fields are never projected; oversized payloads are @@ -847,21 +853,31 @@ message ExecuteLocalAppScenarioRequest { oneof spec { LocalAppTextEmbedScenarioSpec text_embed = 1; LocalAppImageGenerateScenarioSpec image_generate = 2; + // Reuse the single text-turn input for synchronous text generation. + StreamLocalAppTextTurnRequest text_generate = 3; } } message LocalAppTextEmbedOutput { repeated EmbeddingVector vectors = 1; + string space_id = 2; } message LocalAppImageGenerateOutput { repeated LocalAppScenarioArtifact artifacts = 1; } +message LocalAppTextGenerateOutput { + // Only text and function ToolCall items are admitted at this App boundary. + repeated TextOutputItem items = 1; + FinishReason finish_reason = 2; +} + message ExecuteLocalAppScenarioResponse { oneof output { LocalAppTextEmbedOutput text_embed = 1; LocalAppImageGenerateOutput image_generate = 2; + LocalAppTextGenerateOutput text_generate = 4; } string trace_id = 3; } @@ -1040,9 +1056,9 @@ message LocalAppScenarioJobEvent { LocalAppScenarioJob job = 5; } -// Streaming TEXT_GENERATE trimmed surface: typed text increments and terminal -// finish or failure state only. Raw chunks, reasoning traces, sources, and -// tool events never reach the Local App stream. +// Single-step TEXT_GENERATE App surface. Function tools, ordered host-provided +// results and structured output reuse canonical text behavior. The external +// AI host owns tool execution and the next request; Runtime owns no agent loop. message StreamLocalAppTextTurnRequest { repeated LocalAppTextCandidateMessage messages = 1; optional float temperature = 2; @@ -1053,10 +1069,26 @@ message StreamLocalAppTextTurnRequest { optional float frequency_penalty = 7; repeated string stop = 8; optional int64 seed = 9; + repeated ToolSpec tools = 10; + ToolChoiceMode tool_choice = 11; + string tool_choice_name = 12; + ResponseFormat response_format = 13; } message LocalAppTextTurnDelta { string text = 1; + uint32 item_index = 2; +} + +message LocalAppTextTurnToolCall { + uint32 item_index = 1; + ToolCall tool_call = 2; +} + +// Opaque transcript continuity, never raw reasoning or a reasoning control. +message LocalAppTextTurnContinuity { + uint32 item_index = 1; + ReasoningContinuityCarrier carrier = 2; } message LocalAppTextTurnCompleted { @@ -1076,6 +1108,8 @@ message StreamLocalAppTextTurnEvent { LocalAppTextTurnDelta delta = 3; LocalAppTextTurnCompleted completed = 4; LocalAppTextTurnFailed failed = 5; + LocalAppTextTurnToolCall tool_call = 6; + LocalAppTextTurnContinuity reasoning_continuity = 7; } } diff --git a/runtime/AGENTS.md b/runtime/AGENTS.md index 34ae1b1627..93bf752586 100644 --- a/runtime/AGENTS.md +++ b/runtime/AGENTS.md @@ -2,7 +2,7 @@ ## Scope - Applies to `runtime/**`. -- Start from the package or consumer that produced the observed failure; inspect Runtime only when the trace, contract, or changed dependency points here. +- Start from the requested Runtime package or the consumer that produced the observed failure. Follow a concrete task question, trace, contract, or changed dependency into other Runtime packages. ## Hard Boundaries - Keep Runtime self-contained: no imports from `sdks/**` or `apps/**`. @@ -12,7 +12,7 @@ - Do not patch runtime gaps with desktop or SDK hardcodes. ## Retrieval Defaults -- Read the failing package, its direct dependencies, and the exact Runtime authority it implements. +- Read the affected package and its direct dependencies; read its exact Runtime authority when semantics or ownership are unresolved. - Read `runtime/catalog/source/**` only for catalog work; inspect generated outputs only for codegen or drift failures. - Skip unrelated Runtime packages, generated files, large fixtures, and historical evidence. diff --git a/runtime/catalog/providers/openai_codex.yaml b/runtime/catalog/providers/openai_codex.yaml index f4fcb61e3a..c709741feb 100644 --- a/runtime/catalog/providers/openai_codex.yaml +++ b/runtime/catalog/providers/openai_codex.yaml @@ -4,6 +4,43 @@ catalog_version: 2026-08-11-openai-codex-provenance-review inventory_mode: static_source default_text_model: gpt-5.6-sol-wm models: + - model_id: gpt-6-astra + provider: openai_codex + model_type: chat + updated_at: 2026-09-13 + capabilities: + - text.generate + pricing: + unit: request + input: unknown + output: unknown + currency: USD + as_of: 2026-09-13 + notes: Subscription-backed Codex route; standard API token pricing does not apply directly. + source_ref: + url: https://developers.openai.com/codex/models + retrieved_at: 2026-09-13 + note: Official Codex model guidance lists gpt-6-astra for desktop, CLI and IDE use; availability depends on account, sign-in method and rollout. + context_window_tokens: 1050000 + - model_id: gpt-5.6-sol + provider: openai_codex + model_type: chat + updated_at: 2026-09-12 + capabilities: + - text.generate + pricing: + unit: request + input: unknown + output: unknown + currency: USD + as_of: 2026-09-12 + notes: Subscription-backed Codex route; standard API pricing does not apply directly. + source_ref: + source_kind: authenticated_provider_inventory + url: https://chatgpt.com/backend-api/codex/models?client_version=1.0.0 + retrieved_at: 2026-09-12 + note: Authenticated non-public provider inventory observed through the Nimi Connector test; gpt-5.6-sol was listed for the connected ChatGPT account. This does not promise availability for every account. + context_window_tokens: 8192 - model_id: gpt-5.6-sol-wm provider: openai_codex model_type: chat diff --git a/runtime/catalog/source/provider-browser-curator-prompt.md b/runtime/catalog/source/provider-browser-curator-prompt.md index 35708cfebf..a9bfa601cf 100644 --- a/runtime/catalog/source/provider-browser-curator-prompt.md +++ b/runtime/catalog/source/provider-browser-curator-prompt.md @@ -15,7 +15,7 @@ Goal: scoped family requires that for comparison Hard constraints: -- runtime/catalog/source/providers/*.source.yaml remains the authority home +- Product authority lives under .nimi/spec/runtime/{ai-provider,model-catalog}.authority.yaml. Provider source YAML and generated snapshots are reviewed projections, not parallel product authority. - your output is a candidate report, not a source mutation - declare whether this run is provider_wide or family_scoped - if family_scoped, explicitly list families_in_scope diff --git a/runtime/catalog/source/providers/openai_codex.source.yaml b/runtime/catalog/source/providers/openai_codex.source.yaml index 104f235d11..a3e144ebdd 100644 --- a/runtime/catalog/source/providers/openai_codex.source.yaml +++ b/runtime/catalog/source/providers/openai_codex.source.yaml @@ -24,6 +24,19 @@ defaults: notes: Codex subscription-backed pricing is not published as standard API token pricing. sources: + - source_id: openai_codex_models_2026_09_13 + url: https://developers.openai.com/codex/models + retrieved_at: 2026-09-13 + note: Official Codex model guidance lists gpt-6-astra for desktop, CLI and IDE use; availability depends on account, sign-in method and rollout. + - source_id: openai_gpt_6_astra_doc + url: https://developers.openai.com/api/docs/models/gpt-6-astra + retrieved_at: 2026-09-13 + note: Official GPT-6 Astra model page documents context capacity, function calling and structured outputs; Codex subscription access and limits remain account-dependent. + - source_id: openai_codex_authenticated_model_inventory_2026_09_12 + source_kind: authenticated_provider_inventory + url: https://chatgpt.com/backend-api/codex/models?client_version=1.0.0 + retrieved_at: 2026-09-12 + note: Authenticated non-public provider inventory observed through the Nimi Connector test; gpt-5.6-sol was listed for the connected ChatGPT account. This does not promise availability for every account. - source_id: openai_codex_auth_doc url: https://developers.openai.com/codex/auth retrieved_at: 2026-04-23 @@ -73,6 +86,33 @@ selection_profiles: rationale: Current Codex-backed image generation route. models: + - model_id: gpt-6-astra + model_type: chat + updated_at: 2026-09-13 + capabilities: [text.generate] + context_window_tokens: 1050000 + pricing: + unit: request + input: unknown + output: unknown + currency: USD + as_of: 2026-09-13 + notes: Subscription-backed Codex route; standard API token pricing does not apply directly. + source_ids: [openai_codex_models_2026_09_13, openai_gpt_6_astra_doc] + + - model_id: gpt-5.6-sol + model_type: chat + updated_at: 2026-09-12 + capabilities: [text.generate] + pricing: + unit: request + input: unknown + output: unknown + currency: USD + as_of: 2026-09-12 + notes: Subscription-backed Codex route; standard API pricing does not apply directly. + source_ids: [openai_codex_authenticated_model_inventory_2026_09_12] + - model_id: gpt-5.6-sol-wm model_type: chat updated_at: 2026-08-09 diff --git a/runtime/gen/runtime/v1/ai.pb.go b/runtime/gen/runtime/v1/ai.pb.go index a452af8825..22692e87b8 100644 --- a/runtime/gen/runtime/v1/ai.pb.go +++ b/runtime/gen/runtime/v1/ai.pb.go @@ -6081,8 +6081,11 @@ func (x *EmbeddingVector) GetValues() []float64 { } type TextEmbedOutput struct { - state protoimpl.MessageState `protogen:"open.v1"` - Vectors []*EmbeddingVector `protobuf:"bytes,1,rep,name=vectors,proto3" json:"vectors,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Vectors []*EmbeddingVector `protobuf:"bytes,1,rep,name=vectors,proto3" json:"vectors,omitempty"` + // Opaque compatibility identity of the captured embedding semantics. + // Compare actual result values before combining vectors or reusing an index. + SpaceId string `protobuf:"bytes,2,opt,name=space_id,json=spaceId,proto3" json:"space_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -6124,6 +6127,13 @@ func (x *TextEmbedOutput) GetVectors() []*EmbeddingVector { return nil } +func (x *TextEmbedOutput) GetSpaceId() string { + if x != nil { + return x.SpaceId + } + return "" +} + type ImageGenerateResult struct { state protoimpl.MessageState `protogen:"open.v1"` Artifacts []*ScenarioArtifact `protobuf:"bytes,1,rep,name=artifacts,proto3" json:"artifacts,omitempty"` @@ -6931,9 +6941,12 @@ func (x *ExecuteScenarioResponse) GetEffectiveInputIdentity() *LoadoutEffectiveI // derives account, App identity, permission and managed local route from the // protected session; callers cannot supply generic Scenario or route fields. type LocalAppTextCandidateMessage struct { - state protoimpl.MessageState `protogen:"open.v1"` - Role string `protobuf:"bytes,1,opt,name=role,proto3" json:"role,omitempty"` - Text string `protobuf:"bytes,2,opt,name=text,proto3" json:"text,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Role string `protobuf:"bytes,1,opt,name=role,proto3" json:"role,omitempty"` + Text string `protobuf:"bytes,2,opt,name=text,proto3" json:"text,omitempty"` + // Ordered assistant transcript is admitted only by text-turn and the + // text.generate execute variant. The narrow candidate operation rejects it. + TurnItems []*TextTurnItem `protobuf:"bytes,3,rep,name=turn_items,json=turnItems,proto3" json:"turn_items,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -6982,6 +6995,13 @@ func (x *LocalAppTextCandidateMessage) GetText() string { return "" } +func (x *LocalAppTextCandidateMessage) GetTurnItems() []*TextTurnItem { + if x != nil { + return x.TurnItems + } + return nil +} + type GenerateLocalAppTextCandidateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Messages []*LocalAppTextCandidateMessage `protobuf:"bytes,1,rep,name=messages,proto3" json:"messages,omitempty"` @@ -7438,6 +7458,7 @@ type ExecuteLocalAppScenarioRequest struct { // // *ExecuteLocalAppScenarioRequest_TextEmbed // *ExecuteLocalAppScenarioRequest_ImageGenerate + // *ExecuteLocalAppScenarioRequest_TextGenerate Spec isExecuteLocalAppScenarioRequest_Spec `protobuf_oneof:"spec"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -7498,6 +7519,15 @@ func (x *ExecuteLocalAppScenarioRequest) GetImageGenerate() *LocalAppImageGenera return nil } +func (x *ExecuteLocalAppScenarioRequest) GetTextGenerate() *StreamLocalAppTextTurnRequest { + if x != nil { + if x, ok := x.Spec.(*ExecuteLocalAppScenarioRequest_TextGenerate); ok { + return x.TextGenerate + } + } + return nil +} + type isExecuteLocalAppScenarioRequest_Spec interface { isExecuteLocalAppScenarioRequest_Spec() } @@ -7510,13 +7540,21 @@ type ExecuteLocalAppScenarioRequest_ImageGenerate struct { ImageGenerate *LocalAppImageGenerateScenarioSpec `protobuf:"bytes,2,opt,name=image_generate,json=imageGenerate,proto3,oneof"` } +type ExecuteLocalAppScenarioRequest_TextGenerate struct { + // Reuse the single text-turn input for synchronous text generation. + TextGenerate *StreamLocalAppTextTurnRequest `protobuf:"bytes,3,opt,name=text_generate,json=textGenerate,proto3,oneof"` +} + func (*ExecuteLocalAppScenarioRequest_TextEmbed) isExecuteLocalAppScenarioRequest_Spec() {} func (*ExecuteLocalAppScenarioRequest_ImageGenerate) isExecuteLocalAppScenarioRequest_Spec() {} +func (*ExecuteLocalAppScenarioRequest_TextGenerate) isExecuteLocalAppScenarioRequest_Spec() {} + type LocalAppTextEmbedOutput struct { state protoimpl.MessageState `protogen:"open.v1"` Vectors []*EmbeddingVector `protobuf:"bytes,1,rep,name=vectors,proto3" json:"vectors,omitempty"` + SpaceId string `protobuf:"bytes,2,opt,name=space_id,json=spaceId,proto3" json:"space_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -7558,6 +7596,13 @@ func (x *LocalAppTextEmbedOutput) GetVectors() []*EmbeddingVector { return nil } +func (x *LocalAppTextEmbedOutput) GetSpaceId() string { + if x != nil { + return x.SpaceId + } + return "" +} + type LocalAppImageGenerateOutput struct { state protoimpl.MessageState `protogen:"open.v1"` Artifacts []*LocalAppScenarioArtifact `protobuf:"bytes,1,rep,name=artifacts,proto3" json:"artifacts,omitempty"` @@ -7602,12 +7647,66 @@ func (x *LocalAppImageGenerateOutput) GetArtifacts() []*LocalAppScenarioArtifact return nil } +type LocalAppTextGenerateOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Only text and function ToolCall items are admitted at this App boundary. + Items []*TextOutputItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` + FinishReason FinishReason `protobuf:"varint,2,opt,name=finish_reason,json=finishReason,proto3,enum=nimi.runtime.v1.FinishReason" json:"finish_reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LocalAppTextGenerateOutput) Reset() { + *x = LocalAppTextGenerateOutput{} + mi := &file_runtime_v1_ai_proto_msgTypes[83] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LocalAppTextGenerateOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LocalAppTextGenerateOutput) ProtoMessage() {} + +func (x *LocalAppTextGenerateOutput) ProtoReflect() protoreflect.Message { + mi := &file_runtime_v1_ai_proto_msgTypes[83] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LocalAppTextGenerateOutput.ProtoReflect.Descriptor instead. +func (*LocalAppTextGenerateOutput) Descriptor() ([]byte, []int) { + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{83} +} + +func (x *LocalAppTextGenerateOutput) GetItems() []*TextOutputItem { + if x != nil { + return x.Items + } + return nil +} + +func (x *LocalAppTextGenerateOutput) GetFinishReason() FinishReason { + if x != nil { + return x.FinishReason + } + return FinishReason_FINISH_REASON_UNSPECIFIED +} + type ExecuteLocalAppScenarioResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Output: // // *ExecuteLocalAppScenarioResponse_TextEmbed // *ExecuteLocalAppScenarioResponse_ImageGenerate + // *ExecuteLocalAppScenarioResponse_TextGenerate Output isExecuteLocalAppScenarioResponse_Output `protobuf_oneof:"output"` TraceId string `protobuf:"bytes,3,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` unknownFields protoimpl.UnknownFields @@ -7616,7 +7715,7 @@ type ExecuteLocalAppScenarioResponse struct { func (x *ExecuteLocalAppScenarioResponse) Reset() { *x = ExecuteLocalAppScenarioResponse{} - mi := &file_runtime_v1_ai_proto_msgTypes[83] + mi := &file_runtime_v1_ai_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7628,7 +7727,7 @@ func (x *ExecuteLocalAppScenarioResponse) String() string { func (*ExecuteLocalAppScenarioResponse) ProtoMessage() {} func (x *ExecuteLocalAppScenarioResponse) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[83] + mi := &file_runtime_v1_ai_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7641,7 +7740,7 @@ func (x *ExecuteLocalAppScenarioResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecuteLocalAppScenarioResponse.ProtoReflect.Descriptor instead. func (*ExecuteLocalAppScenarioResponse) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{83} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{84} } func (x *ExecuteLocalAppScenarioResponse) GetOutput() isExecuteLocalAppScenarioResponse_Output { @@ -7669,6 +7768,15 @@ func (x *ExecuteLocalAppScenarioResponse) GetImageGenerate() *LocalAppImageGener return nil } +func (x *ExecuteLocalAppScenarioResponse) GetTextGenerate() *LocalAppTextGenerateOutput { + if x != nil { + if x, ok := x.Output.(*ExecuteLocalAppScenarioResponse_TextGenerate); ok { + return x.TextGenerate + } + } + return nil +} + func (x *ExecuteLocalAppScenarioResponse) GetTraceId() string { if x != nil { return x.TraceId @@ -7688,10 +7796,16 @@ type ExecuteLocalAppScenarioResponse_ImageGenerate struct { ImageGenerate *LocalAppImageGenerateOutput `protobuf:"bytes,2,opt,name=image_generate,json=imageGenerate,proto3,oneof"` } +type ExecuteLocalAppScenarioResponse_TextGenerate struct { + TextGenerate *LocalAppTextGenerateOutput `protobuf:"bytes,4,opt,name=text_generate,json=textGenerate,proto3,oneof"` +} + func (*ExecuteLocalAppScenarioResponse_TextEmbed) isExecuteLocalAppScenarioResponse_Output() {} func (*ExecuteLocalAppScenarioResponse_ImageGenerate) isExecuteLocalAppScenarioResponse_Output() {} +func (*ExecuteLocalAppScenarioResponse_TextGenerate) isExecuteLocalAppScenarioResponse_Output() {} + // Asynchronous closed-set Job specs admitted for SubmitLocalAppScenarioJob. // Per-capability selection happens through the scenario spec type plus the // calling App owner's committed AIConfig capability intent, never through @@ -7715,7 +7829,7 @@ type LocalAppVideoGenerationOptions struct { func (x *LocalAppVideoGenerationOptions) Reset() { *x = LocalAppVideoGenerationOptions{} - mi := &file_runtime_v1_ai_proto_msgTypes[84] + mi := &file_runtime_v1_ai_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7727,7 +7841,7 @@ func (x *LocalAppVideoGenerationOptions) String() string { func (*LocalAppVideoGenerationOptions) ProtoMessage() {} func (x *LocalAppVideoGenerationOptions) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[84] + mi := &file_runtime_v1_ai_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7740,7 +7854,7 @@ func (x *LocalAppVideoGenerationOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalAppVideoGenerationOptions.ProtoReflect.Descriptor instead. func (*LocalAppVideoGenerationOptions) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{84} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{85} } func (x *LocalAppVideoGenerationOptions) GetResolution() string { @@ -7833,7 +7947,7 @@ type LocalAppVideoGenerateJobSpec struct { func (x *LocalAppVideoGenerateJobSpec) Reset() { *x = LocalAppVideoGenerateJobSpec{} - mi := &file_runtime_v1_ai_proto_msgTypes[85] + mi := &file_runtime_v1_ai_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7845,7 +7959,7 @@ func (x *LocalAppVideoGenerateJobSpec) String() string { func (*LocalAppVideoGenerateJobSpec) ProtoMessage() {} func (x *LocalAppVideoGenerateJobSpec) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[85] + mi := &file_runtime_v1_ai_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7858,7 +7972,7 @@ func (x *LocalAppVideoGenerateJobSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalAppVideoGenerateJobSpec.ProtoReflect.Descriptor instead. func (*LocalAppVideoGenerateJobSpec) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{85} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{86} } func (x *LocalAppVideoGenerateJobSpec) GetPrompt() string { @@ -7915,7 +8029,7 @@ type LocalAppSpeechSynthesizeJobSpec struct { func (x *LocalAppSpeechSynthesizeJobSpec) Reset() { *x = LocalAppSpeechSynthesizeJobSpec{} - mi := &file_runtime_v1_ai_proto_msgTypes[86] + mi := &file_runtime_v1_ai_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7927,7 +8041,7 @@ func (x *LocalAppSpeechSynthesizeJobSpec) String() string { func (*LocalAppSpeechSynthesizeJobSpec) ProtoMessage() {} func (x *LocalAppSpeechSynthesizeJobSpec) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[86] + mi := &file_runtime_v1_ai_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7940,7 +8054,7 @@ func (x *LocalAppSpeechSynthesizeJobSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalAppSpeechSynthesizeJobSpec.ProtoReflect.Descriptor instead. func (*LocalAppSpeechSynthesizeJobSpec) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{86} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{87} } func (x *LocalAppSpeechSynthesizeJobSpec) GetText() string { @@ -8036,7 +8150,7 @@ type LocalAppSpeechTranscribeJobSpec struct { func (x *LocalAppSpeechTranscribeJobSpec) Reset() { *x = LocalAppSpeechTranscribeJobSpec{} - mi := &file_runtime_v1_ai_proto_msgTypes[87] + mi := &file_runtime_v1_ai_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8048,7 +8162,7 @@ func (x *LocalAppSpeechTranscribeJobSpec) String() string { func (*LocalAppSpeechTranscribeJobSpec) ProtoMessage() {} func (x *LocalAppSpeechTranscribeJobSpec) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[87] + mi := &file_runtime_v1_ai_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8061,7 +8175,7 @@ func (x *LocalAppSpeechTranscribeJobSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalAppSpeechTranscribeJobSpec.ProtoReflect.Descriptor instead. func (*LocalAppSpeechTranscribeJobSpec) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{87} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{88} } func (x *LocalAppSpeechTranscribeJobSpec) GetMimeType() string { @@ -8135,7 +8249,7 @@ type LocalAppVoiceCreateJobSpec struct { func (x *LocalAppVoiceCreateJobSpec) Reset() { *x = LocalAppVoiceCreateJobSpec{} - mi := &file_runtime_v1_ai_proto_msgTypes[88] + mi := &file_runtime_v1_ai_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8147,7 +8261,7 @@ func (x *LocalAppVoiceCreateJobSpec) String() string { func (*LocalAppVoiceCreateJobSpec) ProtoMessage() {} func (x *LocalAppVoiceCreateJobSpec) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[88] + mi := &file_runtime_v1_ai_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8160,7 +8274,7 @@ func (x *LocalAppVoiceCreateJobSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalAppVoiceCreateJobSpec.ProtoReflect.Descriptor instead. func (*LocalAppVoiceCreateJobSpec) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{88} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{89} } func (x *LocalAppVoiceCreateJobSpec) GetSource() isLocalAppVoiceCreateJobSpec_Source { @@ -8216,7 +8330,7 @@ type LocalAppMusicGenerateJobSpec struct { func (x *LocalAppMusicGenerateJobSpec) Reset() { *x = LocalAppMusicGenerateJobSpec{} - mi := &file_runtime_v1_ai_proto_msgTypes[89] + mi := &file_runtime_v1_ai_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8228,7 +8342,7 @@ func (x *LocalAppMusicGenerateJobSpec) String() string { func (*LocalAppMusicGenerateJobSpec) ProtoMessage() {} func (x *LocalAppMusicGenerateJobSpec) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[89] + mi := &file_runtime_v1_ai_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8241,7 +8355,7 @@ func (x *LocalAppMusicGenerateJobSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalAppMusicGenerateJobSpec.ProtoReflect.Descriptor instead. func (*LocalAppMusicGenerateJobSpec) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{89} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{90} } func (x *LocalAppMusicGenerateJobSpec) GetPrompt() string { @@ -8277,7 +8391,7 @@ type LocalAppWorldGenerateJobSpec struct { func (x *LocalAppWorldGenerateJobSpec) Reset() { *x = LocalAppWorldGenerateJobSpec{} - mi := &file_runtime_v1_ai_proto_msgTypes[90] + mi := &file_runtime_v1_ai_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8289,7 +8403,7 @@ func (x *LocalAppWorldGenerateJobSpec) String() string { func (*LocalAppWorldGenerateJobSpec) ProtoMessage() {} func (x *LocalAppWorldGenerateJobSpec) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[90] + mi := &file_runtime_v1_ai_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8302,7 +8416,7 @@ func (x *LocalAppWorldGenerateJobSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalAppWorldGenerateJobSpec.ProtoReflect.Descriptor instead. func (*LocalAppWorldGenerateJobSpec) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{90} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{91} } func (x *LocalAppWorldGenerateJobSpec) GetPrompt() string { @@ -8343,7 +8457,7 @@ type SubmitLocalAppScenarioJobRequest struct { func (x *SubmitLocalAppScenarioJobRequest) Reset() { *x = SubmitLocalAppScenarioJobRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[91] + mi := &file_runtime_v1_ai_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8355,7 +8469,7 @@ func (x *SubmitLocalAppScenarioJobRequest) String() string { func (*SubmitLocalAppScenarioJobRequest) ProtoMessage() {} func (x *SubmitLocalAppScenarioJobRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[91] + mi := &file_runtime_v1_ai_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8368,7 +8482,7 @@ func (x *SubmitLocalAppScenarioJobRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitLocalAppScenarioJobRequest.ProtoReflect.Descriptor instead. func (*SubmitLocalAppScenarioJobRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{91} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{92} } func (x *SubmitLocalAppScenarioJobRequest) GetSpec() isSubmitLocalAppScenarioJobRequest_Spec { @@ -8570,7 +8684,7 @@ type LocalAppScenarioJob struct { func (x *LocalAppScenarioJob) Reset() { *x = LocalAppScenarioJob{} - mi := &file_runtime_v1_ai_proto_msgTypes[92] + mi := &file_runtime_v1_ai_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8582,7 +8696,7 @@ func (x *LocalAppScenarioJob) String() string { func (*LocalAppScenarioJob) ProtoMessage() {} func (x *LocalAppScenarioJob) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[92] + mi := &file_runtime_v1_ai_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8595,7 +8709,7 @@ func (x *LocalAppScenarioJob) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalAppScenarioJob.ProtoReflect.Descriptor instead. func (*LocalAppScenarioJob) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{92} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{93} } func (x *LocalAppScenarioJob) GetJobId() string { @@ -8719,7 +8833,7 @@ type LocalAppVoiceAsset struct { func (x *LocalAppVoiceAsset) Reset() { *x = LocalAppVoiceAsset{} - mi := &file_runtime_v1_ai_proto_msgTypes[93] + mi := &file_runtime_v1_ai_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8731,7 +8845,7 @@ func (x *LocalAppVoiceAsset) String() string { func (*LocalAppVoiceAsset) ProtoMessage() {} func (x *LocalAppVoiceAsset) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[93] + mi := &file_runtime_v1_ai_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8744,7 +8858,7 @@ func (x *LocalAppVoiceAsset) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalAppVoiceAsset.ProtoReflect.Descriptor instead. func (*LocalAppVoiceAsset) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{93} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{94} } func (x *LocalAppVoiceAsset) GetVoiceAssetId() string { @@ -8798,7 +8912,7 @@ type SubmitLocalAppScenarioJobResponse struct { func (x *SubmitLocalAppScenarioJobResponse) Reset() { *x = SubmitLocalAppScenarioJobResponse{} - mi := &file_runtime_v1_ai_proto_msgTypes[94] + mi := &file_runtime_v1_ai_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8810,7 +8924,7 @@ func (x *SubmitLocalAppScenarioJobResponse) String() string { func (*SubmitLocalAppScenarioJobResponse) ProtoMessage() {} func (x *SubmitLocalAppScenarioJobResponse) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[94] + mi := &file_runtime_v1_ai_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8823,7 +8937,7 @@ func (x *SubmitLocalAppScenarioJobResponse) ProtoReflect() protoreflect.Message // Deprecated: Use SubmitLocalAppScenarioJobResponse.ProtoReflect.Descriptor instead. func (*SubmitLocalAppScenarioJobResponse) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{94} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{95} } func (x *SubmitLocalAppScenarioJobResponse) GetJob() *LocalAppScenarioJob { @@ -8842,7 +8956,7 @@ type GetLocalAppScenarioJobRequest struct { func (x *GetLocalAppScenarioJobRequest) Reset() { *x = GetLocalAppScenarioJobRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[95] + mi := &file_runtime_v1_ai_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8854,7 +8968,7 @@ func (x *GetLocalAppScenarioJobRequest) String() string { func (*GetLocalAppScenarioJobRequest) ProtoMessage() {} func (x *GetLocalAppScenarioJobRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[95] + mi := &file_runtime_v1_ai_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8867,7 +8981,7 @@ func (x *GetLocalAppScenarioJobRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLocalAppScenarioJobRequest.ProtoReflect.Descriptor instead. func (*GetLocalAppScenarioJobRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{95} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{96} } func (x *GetLocalAppScenarioJobRequest) GetJobId() string { @@ -8892,7 +9006,7 @@ type GetLocalAppScenarioJobResponse struct { func (x *GetLocalAppScenarioJobResponse) Reset() { *x = GetLocalAppScenarioJobResponse{} - mi := &file_runtime_v1_ai_proto_msgTypes[96] + mi := &file_runtime_v1_ai_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8904,7 +9018,7 @@ func (x *GetLocalAppScenarioJobResponse) String() string { func (*GetLocalAppScenarioJobResponse) ProtoMessage() {} func (x *GetLocalAppScenarioJobResponse) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[96] + mi := &file_runtime_v1_ai_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8917,7 +9031,7 @@ func (x *GetLocalAppScenarioJobResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLocalAppScenarioJobResponse.ProtoReflect.Descriptor instead. func (*GetLocalAppScenarioJobResponse) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{96} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{97} } func (x *GetLocalAppScenarioJobResponse) GetJob() *LocalAppScenarioJob { @@ -8958,7 +9072,7 @@ type CancelLocalAppScenarioJobRequest struct { func (x *CancelLocalAppScenarioJobRequest) Reset() { *x = CancelLocalAppScenarioJobRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[97] + mi := &file_runtime_v1_ai_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8970,7 +9084,7 @@ func (x *CancelLocalAppScenarioJobRequest) String() string { func (*CancelLocalAppScenarioJobRequest) ProtoMessage() {} func (x *CancelLocalAppScenarioJobRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[97] + mi := &file_runtime_v1_ai_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8983,7 +9097,7 @@ func (x *CancelLocalAppScenarioJobRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CancelLocalAppScenarioJobRequest.ProtoReflect.Descriptor instead. func (*CancelLocalAppScenarioJobRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{97} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{98} } func (x *CancelLocalAppScenarioJobRequest) GetJobId() string { @@ -9009,7 +9123,7 @@ type CancelLocalAppScenarioJobResponse struct { func (x *CancelLocalAppScenarioJobResponse) Reset() { *x = CancelLocalAppScenarioJobResponse{} - mi := &file_runtime_v1_ai_proto_msgTypes[98] + mi := &file_runtime_v1_ai_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9021,7 +9135,7 @@ func (x *CancelLocalAppScenarioJobResponse) String() string { func (*CancelLocalAppScenarioJobResponse) ProtoMessage() {} func (x *CancelLocalAppScenarioJobResponse) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[98] + mi := &file_runtime_v1_ai_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9034,7 +9148,7 @@ func (x *CancelLocalAppScenarioJobResponse) ProtoReflect() protoreflect.Message // Deprecated: Use CancelLocalAppScenarioJobResponse.ProtoReflect.Descriptor instead. func (*CancelLocalAppScenarioJobResponse) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{98} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{99} } func (x *CancelLocalAppScenarioJobResponse) GetJob() *LocalAppScenarioJob { @@ -9053,7 +9167,7 @@ type SubscribeLocalAppScenarioJobEventsRequest struct { func (x *SubscribeLocalAppScenarioJobEventsRequest) Reset() { *x = SubscribeLocalAppScenarioJobEventsRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[99] + mi := &file_runtime_v1_ai_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9065,7 +9179,7 @@ func (x *SubscribeLocalAppScenarioJobEventsRequest) String() string { func (*SubscribeLocalAppScenarioJobEventsRequest) ProtoMessage() {} func (x *SubscribeLocalAppScenarioJobEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[99] + mi := &file_runtime_v1_ai_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9078,7 +9192,7 @@ func (x *SubscribeLocalAppScenarioJobEventsRequest) ProtoReflect() protoreflect. // Deprecated: Use SubscribeLocalAppScenarioJobEventsRequest.ProtoReflect.Descriptor instead. func (*SubscribeLocalAppScenarioJobEventsRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{99} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{100} } func (x *SubscribeLocalAppScenarioJobEventsRequest) GetJobId() string { @@ -9101,7 +9215,7 @@ type LocalAppScenarioJobEvent struct { func (x *LocalAppScenarioJobEvent) Reset() { *x = LocalAppScenarioJobEvent{} - mi := &file_runtime_v1_ai_proto_msgTypes[100] + mi := &file_runtime_v1_ai_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9113,7 +9227,7 @@ func (x *LocalAppScenarioJobEvent) String() string { func (*LocalAppScenarioJobEvent) ProtoMessage() {} func (x *LocalAppScenarioJobEvent) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[100] + mi := &file_runtime_v1_ai_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9126,7 +9240,7 @@ func (x *LocalAppScenarioJobEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalAppScenarioJobEvent.ProtoReflect.Descriptor instead. func (*LocalAppScenarioJobEvent) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{100} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{101} } func (x *LocalAppScenarioJobEvent) GetEventType() ScenarioJobEventType { @@ -9164,9 +9278,9 @@ func (x *LocalAppScenarioJobEvent) GetJob() *LocalAppScenarioJob { return nil } -// Streaming TEXT_GENERATE trimmed surface: typed text increments and terminal -// finish or failure state only. Raw chunks, reasoning traces, sources, and -// tool events never reach the Local App stream. +// Single-step TEXT_GENERATE App surface. Function tools, ordered host-provided +// results and structured output reuse canonical text behavior. The external +// AI host owns tool execution and the next request; Runtime owns no agent loop. type StreamLocalAppTextTurnRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Messages []*LocalAppTextCandidateMessage `protobuf:"bytes,1,rep,name=messages,proto3" json:"messages,omitempty"` @@ -9178,13 +9292,17 @@ type StreamLocalAppTextTurnRequest struct { FrequencyPenalty *float32 `protobuf:"fixed32,7,opt,name=frequency_penalty,json=frequencyPenalty,proto3,oneof" json:"frequency_penalty,omitempty"` Stop []string `protobuf:"bytes,8,rep,name=stop,proto3" json:"stop,omitempty"` Seed *int64 `protobuf:"varint,9,opt,name=seed,proto3,oneof" json:"seed,omitempty"` + Tools []*ToolSpec `protobuf:"bytes,10,rep,name=tools,proto3" json:"tools,omitempty"` + ToolChoice ToolChoiceMode `protobuf:"varint,11,opt,name=tool_choice,json=toolChoice,proto3,enum=nimi.runtime.v1.ToolChoiceMode" json:"tool_choice,omitempty"` + ToolChoiceName string `protobuf:"bytes,12,opt,name=tool_choice_name,json=toolChoiceName,proto3" json:"tool_choice_name,omitempty"` + ResponseFormat *ResponseFormat `protobuf:"bytes,13,opt,name=response_format,json=responseFormat,proto3" json:"response_format,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StreamLocalAppTextTurnRequest) Reset() { *x = StreamLocalAppTextTurnRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[101] + mi := &file_runtime_v1_ai_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9196,7 +9314,7 @@ func (x *StreamLocalAppTextTurnRequest) String() string { func (*StreamLocalAppTextTurnRequest) ProtoMessage() {} func (x *StreamLocalAppTextTurnRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[101] + mi := &file_runtime_v1_ai_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9209,7 +9327,7 @@ func (x *StreamLocalAppTextTurnRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamLocalAppTextTurnRequest.ProtoReflect.Descriptor instead. func (*StreamLocalAppTextTurnRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{101} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{102} } func (x *StreamLocalAppTextTurnRequest) GetMessages() []*LocalAppTextCandidateMessage { @@ -9275,16 +9393,45 @@ func (x *StreamLocalAppTextTurnRequest) GetSeed() int64 { return 0 } +func (x *StreamLocalAppTextTurnRequest) GetTools() []*ToolSpec { + if x != nil { + return x.Tools + } + return nil +} + +func (x *StreamLocalAppTextTurnRequest) GetToolChoice() ToolChoiceMode { + if x != nil { + return x.ToolChoice + } + return ToolChoiceMode_TOOL_CHOICE_MODE_UNSPECIFIED +} + +func (x *StreamLocalAppTextTurnRequest) GetToolChoiceName() string { + if x != nil { + return x.ToolChoiceName + } + return "" +} + +func (x *StreamLocalAppTextTurnRequest) GetResponseFormat() *ResponseFormat { + if x != nil { + return x.ResponseFormat + } + return nil +} + type LocalAppTextTurnDelta struct { state protoimpl.MessageState `protogen:"open.v1"` Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + ItemIndex uint32 `protobuf:"varint,2,opt,name=item_index,json=itemIndex,proto3" json:"item_index,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *LocalAppTextTurnDelta) Reset() { *x = LocalAppTextTurnDelta{} - mi := &file_runtime_v1_ai_proto_msgTypes[102] + mi := &file_runtime_v1_ai_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9296,7 +9443,7 @@ func (x *LocalAppTextTurnDelta) String() string { func (*LocalAppTextTurnDelta) ProtoMessage() {} func (x *LocalAppTextTurnDelta) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[102] + mi := &file_runtime_v1_ai_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9309,7 +9456,7 @@ func (x *LocalAppTextTurnDelta) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalAppTextTurnDelta.ProtoReflect.Descriptor instead. func (*LocalAppTextTurnDelta) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{102} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{103} } func (x *LocalAppTextTurnDelta) GetText() string { @@ -9319,6 +9466,118 @@ func (x *LocalAppTextTurnDelta) GetText() string { return "" } +func (x *LocalAppTextTurnDelta) GetItemIndex() uint32 { + if x != nil { + return x.ItemIndex + } + return 0 +} + +type LocalAppTextTurnToolCall struct { + state protoimpl.MessageState `protogen:"open.v1"` + ItemIndex uint32 `protobuf:"varint,1,opt,name=item_index,json=itemIndex,proto3" json:"item_index,omitempty"` + ToolCall *ToolCall `protobuf:"bytes,2,opt,name=tool_call,json=toolCall,proto3" json:"tool_call,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LocalAppTextTurnToolCall) Reset() { + *x = LocalAppTextTurnToolCall{} + mi := &file_runtime_v1_ai_proto_msgTypes[104] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LocalAppTextTurnToolCall) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LocalAppTextTurnToolCall) ProtoMessage() {} + +func (x *LocalAppTextTurnToolCall) ProtoReflect() protoreflect.Message { + mi := &file_runtime_v1_ai_proto_msgTypes[104] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LocalAppTextTurnToolCall.ProtoReflect.Descriptor instead. +func (*LocalAppTextTurnToolCall) Descriptor() ([]byte, []int) { + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{104} +} + +func (x *LocalAppTextTurnToolCall) GetItemIndex() uint32 { + if x != nil { + return x.ItemIndex + } + return 0 +} + +func (x *LocalAppTextTurnToolCall) GetToolCall() *ToolCall { + if x != nil { + return x.ToolCall + } + return nil +} + +// Opaque transcript continuity, never raw reasoning or a reasoning control. +type LocalAppTextTurnContinuity struct { + state protoimpl.MessageState `protogen:"open.v1"` + ItemIndex uint32 `protobuf:"varint,1,opt,name=item_index,json=itemIndex,proto3" json:"item_index,omitempty"` + Carrier *ReasoningContinuityCarrier `protobuf:"bytes,2,opt,name=carrier,proto3" json:"carrier,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LocalAppTextTurnContinuity) Reset() { + *x = LocalAppTextTurnContinuity{} + mi := &file_runtime_v1_ai_proto_msgTypes[105] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LocalAppTextTurnContinuity) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LocalAppTextTurnContinuity) ProtoMessage() {} + +func (x *LocalAppTextTurnContinuity) ProtoReflect() protoreflect.Message { + mi := &file_runtime_v1_ai_proto_msgTypes[105] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LocalAppTextTurnContinuity.ProtoReflect.Descriptor instead. +func (*LocalAppTextTurnContinuity) Descriptor() ([]byte, []int) { + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{105} +} + +func (x *LocalAppTextTurnContinuity) GetItemIndex() uint32 { + if x != nil { + return x.ItemIndex + } + return 0 +} + +func (x *LocalAppTextTurnContinuity) GetCarrier() *ReasoningContinuityCarrier { + if x != nil { + return x.Carrier + } + return nil +} + type LocalAppTextTurnCompleted struct { state protoimpl.MessageState `protogen:"open.v1"` FinishReason FinishReason `protobuf:"varint,1,opt,name=finish_reason,json=finishReason,proto3,enum=nimi.runtime.v1.FinishReason" json:"finish_reason,omitempty"` @@ -9328,7 +9587,7 @@ type LocalAppTextTurnCompleted struct { func (x *LocalAppTextTurnCompleted) Reset() { *x = LocalAppTextTurnCompleted{} - mi := &file_runtime_v1_ai_proto_msgTypes[103] + mi := &file_runtime_v1_ai_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9340,7 +9599,7 @@ func (x *LocalAppTextTurnCompleted) String() string { func (*LocalAppTextTurnCompleted) ProtoMessage() {} func (x *LocalAppTextTurnCompleted) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[103] + mi := &file_runtime_v1_ai_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9353,7 +9612,7 @@ func (x *LocalAppTextTurnCompleted) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalAppTextTurnCompleted.ProtoReflect.Descriptor instead. func (*LocalAppTextTurnCompleted) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{103} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{106} } func (x *LocalAppTextTurnCompleted) GetFinishReason() FinishReason { @@ -9374,7 +9633,7 @@ type LocalAppTextTurnFailed struct { func (x *LocalAppTextTurnFailed) Reset() { *x = LocalAppTextTurnFailed{} - mi := &file_runtime_v1_ai_proto_msgTypes[104] + mi := &file_runtime_v1_ai_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9386,7 +9645,7 @@ func (x *LocalAppTextTurnFailed) String() string { func (*LocalAppTextTurnFailed) ProtoMessage() {} func (x *LocalAppTextTurnFailed) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[104] + mi := &file_runtime_v1_ai_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9399,7 +9658,7 @@ func (x *LocalAppTextTurnFailed) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalAppTextTurnFailed.ProtoReflect.Descriptor instead. func (*LocalAppTextTurnFailed) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{104} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{107} } func (x *LocalAppTextTurnFailed) GetReasonCode() ReasonCode { @@ -9432,6 +9691,8 @@ type StreamLocalAppTextTurnEvent struct { // *StreamLocalAppTextTurnEvent_Delta // *StreamLocalAppTextTurnEvent_Completed // *StreamLocalAppTextTurnEvent_Failed + // *StreamLocalAppTextTurnEvent_ToolCall + // *StreamLocalAppTextTurnEvent_ReasoningContinuity Payload isStreamLocalAppTextTurnEvent_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -9439,7 +9700,7 @@ type StreamLocalAppTextTurnEvent struct { func (x *StreamLocalAppTextTurnEvent) Reset() { *x = StreamLocalAppTextTurnEvent{} - mi := &file_runtime_v1_ai_proto_msgTypes[105] + mi := &file_runtime_v1_ai_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9451,7 +9712,7 @@ func (x *StreamLocalAppTextTurnEvent) String() string { func (*StreamLocalAppTextTurnEvent) ProtoMessage() {} func (x *StreamLocalAppTextTurnEvent) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[105] + mi := &file_runtime_v1_ai_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9464,7 +9725,7 @@ func (x *StreamLocalAppTextTurnEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamLocalAppTextTurnEvent.ProtoReflect.Descriptor instead. func (*StreamLocalAppTextTurnEvent) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{105} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{108} } func (x *StreamLocalAppTextTurnEvent) GetSequence() uint64 { @@ -9515,6 +9776,24 @@ func (x *StreamLocalAppTextTurnEvent) GetFailed() *LocalAppTextTurnFailed { return nil } +func (x *StreamLocalAppTextTurnEvent) GetToolCall() *LocalAppTextTurnToolCall { + if x != nil { + if x, ok := x.Payload.(*StreamLocalAppTextTurnEvent_ToolCall); ok { + return x.ToolCall + } + } + return nil +} + +func (x *StreamLocalAppTextTurnEvent) GetReasoningContinuity() *LocalAppTextTurnContinuity { + if x != nil { + if x, ok := x.Payload.(*StreamLocalAppTextTurnEvent_ReasoningContinuity); ok { + return x.ReasoningContinuity + } + } + return nil +} + type isStreamLocalAppTextTurnEvent_Payload interface { isStreamLocalAppTextTurnEvent_Payload() } @@ -9531,12 +9810,24 @@ type StreamLocalAppTextTurnEvent_Failed struct { Failed *LocalAppTextTurnFailed `protobuf:"bytes,5,opt,name=failed,proto3,oneof"` } +type StreamLocalAppTextTurnEvent_ToolCall struct { + ToolCall *LocalAppTextTurnToolCall `protobuf:"bytes,6,opt,name=tool_call,json=toolCall,proto3,oneof"` +} + +type StreamLocalAppTextTurnEvent_ReasoningContinuity struct { + ReasoningContinuity *LocalAppTextTurnContinuity `protobuf:"bytes,7,opt,name=reasoning_continuity,json=reasoningContinuity,proto3,oneof"` +} + func (*StreamLocalAppTextTurnEvent_Delta) isStreamLocalAppTextTurnEvent_Payload() {} func (*StreamLocalAppTextTurnEvent_Completed) isStreamLocalAppTextTurnEvent_Payload() {} func (*StreamLocalAppTextTurnEvent_Failed) isStreamLocalAppTextTurnEvent_Payload() {} +func (*StreamLocalAppTextTurnEvent_ToolCall) isStreamLocalAppTextTurnEvent_Payload() {} + +func (*StreamLocalAppTextTurnEvent_ReasoningContinuity) isStreamLocalAppTextTurnEvent_Payload() {} + // Bounded inline artifact read limited to artifacts owned by the calling App // session owner; payloads above 32 MiB fail closed with ARTIFACT_TOO_LARGE. type ReadLocalAppArtifactRequest struct { @@ -9548,7 +9839,7 @@ type ReadLocalAppArtifactRequest struct { func (x *ReadLocalAppArtifactRequest) Reset() { *x = ReadLocalAppArtifactRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[106] + mi := &file_runtime_v1_ai_proto_msgTypes[109] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9560,7 +9851,7 @@ func (x *ReadLocalAppArtifactRequest) String() string { func (*ReadLocalAppArtifactRequest) ProtoMessage() {} func (x *ReadLocalAppArtifactRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[106] + mi := &file_runtime_v1_ai_proto_msgTypes[109] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9573,7 +9864,7 @@ func (x *ReadLocalAppArtifactRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadLocalAppArtifactRequest.ProtoReflect.Descriptor instead. func (*ReadLocalAppArtifactRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{106} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{109} } func (x *ReadLocalAppArtifactRequest) GetArtifactId() string { @@ -9594,7 +9885,7 @@ type ReadLocalAppArtifactResponse struct { func (x *ReadLocalAppArtifactResponse) Reset() { *x = ReadLocalAppArtifactResponse{} - mi := &file_runtime_v1_ai_proto_msgTypes[107] + mi := &file_runtime_v1_ai_proto_msgTypes[110] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9606,7 +9897,7 @@ func (x *ReadLocalAppArtifactResponse) String() string { func (*ReadLocalAppArtifactResponse) ProtoMessage() {} func (x *ReadLocalAppArtifactResponse) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[107] + mi := &file_runtime_v1_ai_proto_msgTypes[110] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9619,7 +9910,7 @@ func (x *ReadLocalAppArtifactResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadLocalAppArtifactResponse.ProtoReflect.Descriptor instead. func (*ReadLocalAppArtifactResponse) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{107} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{110} } func (x *ReadLocalAppArtifactResponse) GetBytes() []byte { @@ -9657,7 +9948,7 @@ type UploadLocalAppArtifactRequest struct { func (x *UploadLocalAppArtifactRequest) Reset() { *x = UploadLocalAppArtifactRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[108] + mi := &file_runtime_v1_ai_proto_msgTypes[111] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9669,7 +9960,7 @@ func (x *UploadLocalAppArtifactRequest) String() string { func (*UploadLocalAppArtifactRequest) ProtoMessage() {} func (x *UploadLocalAppArtifactRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[108] + mi := &file_runtime_v1_ai_proto_msgTypes[111] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9682,7 +9973,7 @@ func (x *UploadLocalAppArtifactRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadLocalAppArtifactRequest.ProtoReflect.Descriptor instead. func (*UploadLocalAppArtifactRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{108} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{111} } func (x *UploadLocalAppArtifactRequest) GetBytes() []byte { @@ -9710,7 +10001,7 @@ type UploadLocalAppArtifactResponse struct { func (x *UploadLocalAppArtifactResponse) Reset() { *x = UploadLocalAppArtifactResponse{} - mi := &file_runtime_v1_ai_proto_msgTypes[109] + mi := &file_runtime_v1_ai_proto_msgTypes[112] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9722,7 +10013,7 @@ func (x *UploadLocalAppArtifactResponse) String() string { func (*UploadLocalAppArtifactResponse) ProtoMessage() {} func (x *UploadLocalAppArtifactResponse) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[109] + mi := &file_runtime_v1_ai_proto_msgTypes[112] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9735,7 +10026,7 @@ func (x *UploadLocalAppArtifactResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadLocalAppArtifactResponse.ProtoReflect.Descriptor instead. func (*UploadLocalAppArtifactResponse) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{109} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{112} } func (x *UploadLocalAppArtifactResponse) GetArtifactId() string { @@ -9769,7 +10060,7 @@ type ListLocalAppVoiceAssetsRequest struct { func (x *ListLocalAppVoiceAssetsRequest) Reset() { *x = ListLocalAppVoiceAssetsRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[110] + mi := &file_runtime_v1_ai_proto_msgTypes[113] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9781,7 +10072,7 @@ func (x *ListLocalAppVoiceAssetsRequest) String() string { func (*ListLocalAppVoiceAssetsRequest) ProtoMessage() {} func (x *ListLocalAppVoiceAssetsRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[110] + mi := &file_runtime_v1_ai_proto_msgTypes[113] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9794,7 +10085,7 @@ func (x *ListLocalAppVoiceAssetsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListLocalAppVoiceAssetsRequest.ProtoReflect.Descriptor instead. func (*ListLocalAppVoiceAssetsRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{110} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{113} } func (x *ListLocalAppVoiceAssetsRequest) GetPageSize() int32 { @@ -9821,7 +10112,7 @@ type ListLocalAppVoiceAssetsResponse struct { func (x *ListLocalAppVoiceAssetsResponse) Reset() { *x = ListLocalAppVoiceAssetsResponse{} - mi := &file_runtime_v1_ai_proto_msgTypes[111] + mi := &file_runtime_v1_ai_proto_msgTypes[114] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9833,7 +10124,7 @@ func (x *ListLocalAppVoiceAssetsResponse) String() string { func (*ListLocalAppVoiceAssetsResponse) ProtoMessage() {} func (x *ListLocalAppVoiceAssetsResponse) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[111] + mi := &file_runtime_v1_ai_proto_msgTypes[114] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9846,7 +10137,7 @@ func (x *ListLocalAppVoiceAssetsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListLocalAppVoiceAssetsResponse.ProtoReflect.Descriptor instead. func (*ListLocalAppVoiceAssetsResponse) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{111} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{114} } func (x *ListLocalAppVoiceAssetsResponse) GetAssets() []*LocalAppVoiceAsset { @@ -9876,7 +10167,7 @@ type StreamScenarioRequest struct { func (x *StreamScenarioRequest) Reset() { *x = StreamScenarioRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[112] + mi := &file_runtime_v1_ai_proto_msgTypes[115] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9888,7 +10179,7 @@ func (x *StreamScenarioRequest) String() string { func (*StreamScenarioRequest) ProtoMessage() {} func (x *StreamScenarioRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[112] + mi := &file_runtime_v1_ai_proto_msgTypes[115] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9901,7 +10192,7 @@ func (x *StreamScenarioRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamScenarioRequest.ProtoReflect.Descriptor instead. func (*StreamScenarioRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{112} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{115} } func (x *StreamScenarioRequest) GetHead() *ScenarioRequestHead { @@ -9957,7 +10248,7 @@ type ScenarioStreamStarted struct { func (x *ScenarioStreamStarted) Reset() { *x = ScenarioStreamStarted{} - mi := &file_runtime_v1_ai_proto_msgTypes[113] + mi := &file_runtime_v1_ai_proto_msgTypes[116] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9969,7 +10260,7 @@ func (x *ScenarioStreamStarted) String() string { func (*ScenarioStreamStarted) ProtoMessage() {} func (x *ScenarioStreamStarted) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[113] + mi := &file_runtime_v1_ai_proto_msgTypes[116] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9982,7 +10273,7 @@ func (x *ScenarioStreamStarted) ProtoReflect() protoreflect.Message { // Deprecated: Use ScenarioStreamStarted.ProtoReflect.Descriptor instead. func (*ScenarioStreamStarted) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{113} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{116} } func (x *ScenarioStreamStarted) GetModelResolved() string { @@ -10023,7 +10314,7 @@ type ArtifactStreamDelta struct { func (x *ArtifactStreamDelta) Reset() { *x = ArtifactStreamDelta{} - mi := &file_runtime_v1_ai_proto_msgTypes[114] + mi := &file_runtime_v1_ai_proto_msgTypes[117] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10035,7 +10326,7 @@ func (x *ArtifactStreamDelta) String() string { func (*ArtifactStreamDelta) ProtoMessage() {} func (x *ArtifactStreamDelta) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[114] + mi := &file_runtime_v1_ai_proto_msgTypes[117] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10048,7 +10339,7 @@ func (x *ArtifactStreamDelta) ProtoReflect() protoreflect.Message { // Deprecated: Use ArtifactStreamDelta.ProtoReflect.Descriptor instead. func (*ArtifactStreamDelta) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{114} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{117} } func (x *ArtifactStreamDelta) GetChunk() []byte { @@ -10080,7 +10371,7 @@ type ScenarioStreamDelta struct { func (x *ScenarioStreamDelta) Reset() { *x = ScenarioStreamDelta{} - mi := &file_runtime_v1_ai_proto_msgTypes[115] + mi := &file_runtime_v1_ai_proto_msgTypes[118] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10092,7 +10383,7 @@ func (x *ScenarioStreamDelta) String() string { func (*ScenarioStreamDelta) ProtoMessage() {} func (x *ScenarioStreamDelta) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[115] + mi := &file_runtime_v1_ai_proto_msgTypes[118] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10105,7 +10396,7 @@ func (x *ScenarioStreamDelta) ProtoReflect() protoreflect.Message { // Deprecated: Use ScenarioStreamDelta.ProtoReflect.Descriptor instead. func (*ScenarioStreamDelta) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{115} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{118} } func (x *ScenarioStreamDelta) GetDelta() isScenarioStreamDelta_Delta { @@ -10195,7 +10486,7 @@ type ScenarioStreamCompleted struct { func (x *ScenarioStreamCompleted) Reset() { *x = ScenarioStreamCompleted{} - mi := &file_runtime_v1_ai_proto_msgTypes[116] + mi := &file_runtime_v1_ai_proto_msgTypes[119] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10207,7 +10498,7 @@ func (x *ScenarioStreamCompleted) String() string { func (*ScenarioStreamCompleted) ProtoMessage() {} func (x *ScenarioStreamCompleted) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[116] + mi := &file_runtime_v1_ai_proto_msgTypes[119] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10220,7 +10511,7 @@ func (x *ScenarioStreamCompleted) ProtoReflect() protoreflect.Message { // Deprecated: Use ScenarioStreamCompleted.ProtoReflect.Descriptor instead. func (*ScenarioStreamCompleted) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{116} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{119} } func (x *ScenarioStreamCompleted) GetFinishReason() FinishReason { @@ -10255,7 +10546,7 @@ type ScenarioStreamFailed struct { func (x *ScenarioStreamFailed) Reset() { *x = ScenarioStreamFailed{} - mi := &file_runtime_v1_ai_proto_msgTypes[117] + mi := &file_runtime_v1_ai_proto_msgTypes[120] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10267,7 +10558,7 @@ func (x *ScenarioStreamFailed) String() string { func (*ScenarioStreamFailed) ProtoMessage() {} func (x *ScenarioStreamFailed) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[117] + mi := &file_runtime_v1_ai_proto_msgTypes[120] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10280,7 +10571,7 @@ func (x *ScenarioStreamFailed) ProtoReflect() protoreflect.Message { // Deprecated: Use ScenarioStreamFailed.ProtoReflect.Descriptor instead. func (*ScenarioStreamFailed) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{117} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{120} } func (x *ScenarioStreamFailed) GetReasonCode() ReasonCode { @@ -10324,7 +10615,7 @@ type StreamScenarioEvent struct { func (x *StreamScenarioEvent) Reset() { *x = StreamScenarioEvent{} - mi := &file_runtime_v1_ai_proto_msgTypes[118] + mi := &file_runtime_v1_ai_proto_msgTypes[121] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10336,7 +10627,7 @@ func (x *StreamScenarioEvent) String() string { func (*StreamScenarioEvent) ProtoMessage() {} func (x *StreamScenarioEvent) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[118] + mi := &file_runtime_v1_ai_proto_msgTypes[121] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10349,7 +10640,7 @@ func (x *StreamScenarioEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamScenarioEvent.ProtoReflect.Descriptor instead. func (*StreamScenarioEvent) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{118} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{121} } func (x *StreamScenarioEvent) GetEventType() StreamEventType { @@ -10491,7 +10782,7 @@ type ScenarioArtifact struct { func (x *ScenarioArtifact) Reset() { *x = ScenarioArtifact{} - mi := &file_runtime_v1_ai_proto_msgTypes[119] + mi := &file_runtime_v1_ai_proto_msgTypes[122] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10503,7 +10794,7 @@ func (x *ScenarioArtifact) String() string { func (*ScenarioArtifact) ProtoMessage() {} func (x *ScenarioArtifact) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[119] + mi := &file_runtime_v1_ai_proto_msgTypes[122] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10516,7 +10807,7 @@ func (x *ScenarioArtifact) ProtoReflect() protoreflect.Message { // Deprecated: Use ScenarioArtifact.ProtoReflect.Descriptor instead. func (*ScenarioArtifact) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{119} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{122} } func (x *ScenarioArtifact) GetArtifactId() string { @@ -10665,7 +10956,7 @@ type ScenarioJob struct { func (x *ScenarioJob) Reset() { *x = ScenarioJob{} - mi := &file_runtime_v1_ai_proto_msgTypes[120] + mi := &file_runtime_v1_ai_proto_msgTypes[123] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10677,7 +10968,7 @@ func (x *ScenarioJob) String() string { func (*ScenarioJob) ProtoMessage() {} func (x *ScenarioJob) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[120] + mi := &file_runtime_v1_ai_proto_msgTypes[123] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10690,7 +10981,7 @@ func (x *ScenarioJob) ProtoReflect() protoreflect.Message { // Deprecated: Use ScenarioJob.ProtoReflect.Descriptor instead. func (*ScenarioJob) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{120} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{123} } func (x *ScenarioJob) GetJobId() string { @@ -10891,7 +11182,7 @@ type SubmitScenarioJobRequest struct { func (x *SubmitScenarioJobRequest) Reset() { *x = SubmitScenarioJobRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[121] + mi := &file_runtime_v1_ai_proto_msgTypes[124] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10903,7 +11194,7 @@ func (x *SubmitScenarioJobRequest) String() string { func (*SubmitScenarioJobRequest) ProtoMessage() {} func (x *SubmitScenarioJobRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[121] + mi := &file_runtime_v1_ai_proto_msgTypes[124] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10916,7 +11207,7 @@ func (x *SubmitScenarioJobRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitScenarioJobRequest.ProtoReflect.Descriptor instead. func (*SubmitScenarioJobRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{121} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{124} } func (x *SubmitScenarioJobRequest) GetHead() *ScenarioRequestHead { @@ -10984,7 +11275,7 @@ type SubmitScenarioJobResponse struct { func (x *SubmitScenarioJobResponse) Reset() { *x = SubmitScenarioJobResponse{} - mi := &file_runtime_v1_ai_proto_msgTypes[122] + mi := &file_runtime_v1_ai_proto_msgTypes[125] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10996,7 +11287,7 @@ func (x *SubmitScenarioJobResponse) String() string { func (*SubmitScenarioJobResponse) ProtoMessage() {} func (x *SubmitScenarioJobResponse) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[122] + mi := &file_runtime_v1_ai_proto_msgTypes[125] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11009,7 +11300,7 @@ func (x *SubmitScenarioJobResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitScenarioJobResponse.ProtoReflect.Descriptor instead. func (*SubmitScenarioJobResponse) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{122} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{125} } func (x *SubmitScenarioJobResponse) GetJob() *ScenarioJob { @@ -11028,7 +11319,7 @@ type GetScenarioJobRequest struct { func (x *GetScenarioJobRequest) Reset() { *x = GetScenarioJobRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[123] + mi := &file_runtime_v1_ai_proto_msgTypes[126] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11040,7 +11331,7 @@ func (x *GetScenarioJobRequest) String() string { func (*GetScenarioJobRequest) ProtoMessage() {} func (x *GetScenarioJobRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[123] + mi := &file_runtime_v1_ai_proto_msgTypes[126] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11053,7 +11344,7 @@ func (x *GetScenarioJobRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetScenarioJobRequest.ProtoReflect.Descriptor instead. func (*GetScenarioJobRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{123} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{126} } func (x *GetScenarioJobRequest) GetJobId() string { @@ -11078,7 +11369,7 @@ type GetScenarioJobResponse struct { func (x *GetScenarioJobResponse) Reset() { *x = GetScenarioJobResponse{} - mi := &file_runtime_v1_ai_proto_msgTypes[124] + mi := &file_runtime_v1_ai_proto_msgTypes[127] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11090,7 +11381,7 @@ func (x *GetScenarioJobResponse) String() string { func (*GetScenarioJobResponse) ProtoMessage() {} func (x *GetScenarioJobResponse) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[124] + mi := &file_runtime_v1_ai_proto_msgTypes[127] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11103,7 +11394,7 @@ func (x *GetScenarioJobResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetScenarioJobResponse.ProtoReflect.Descriptor instead. func (*GetScenarioJobResponse) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{124} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{127} } func (x *GetScenarioJobResponse) GetJob() *ScenarioJob { @@ -11144,7 +11435,7 @@ type CancelScenarioJobRequest struct { func (x *CancelScenarioJobRequest) Reset() { *x = CancelScenarioJobRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[125] + mi := &file_runtime_v1_ai_proto_msgTypes[128] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11156,7 +11447,7 @@ func (x *CancelScenarioJobRequest) String() string { func (*CancelScenarioJobRequest) ProtoMessage() {} func (x *CancelScenarioJobRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[125] + mi := &file_runtime_v1_ai_proto_msgTypes[128] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11169,7 +11460,7 @@ func (x *CancelScenarioJobRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CancelScenarioJobRequest.ProtoReflect.Descriptor instead. func (*CancelScenarioJobRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{125} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{128} } func (x *CancelScenarioJobRequest) GetJobId() string { @@ -11195,7 +11486,7 @@ type CancelScenarioJobResponse struct { func (x *CancelScenarioJobResponse) Reset() { *x = CancelScenarioJobResponse{} - mi := &file_runtime_v1_ai_proto_msgTypes[126] + mi := &file_runtime_v1_ai_proto_msgTypes[129] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11207,7 +11498,7 @@ func (x *CancelScenarioJobResponse) String() string { func (*CancelScenarioJobResponse) ProtoMessage() {} func (x *CancelScenarioJobResponse) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[126] + mi := &file_runtime_v1_ai_proto_msgTypes[129] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11220,7 +11511,7 @@ func (x *CancelScenarioJobResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CancelScenarioJobResponse.ProtoReflect.Descriptor instead. func (*CancelScenarioJobResponse) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{126} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{129} } func (x *CancelScenarioJobResponse) GetJob() *ScenarioJob { @@ -11243,7 +11534,7 @@ type ScenarioJobEvent struct { func (x *ScenarioJobEvent) Reset() { *x = ScenarioJobEvent{} - mi := &file_runtime_v1_ai_proto_msgTypes[127] + mi := &file_runtime_v1_ai_proto_msgTypes[130] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11255,7 +11546,7 @@ func (x *ScenarioJobEvent) String() string { func (*ScenarioJobEvent) ProtoMessage() {} func (x *ScenarioJobEvent) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[127] + mi := &file_runtime_v1_ai_proto_msgTypes[130] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11268,7 +11559,7 @@ func (x *ScenarioJobEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ScenarioJobEvent.ProtoReflect.Descriptor instead. func (*ScenarioJobEvent) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{127} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{130} } func (x *ScenarioJobEvent) GetEventType() ScenarioJobEventType { @@ -11315,7 +11606,7 @@ type SubscribeScenarioJobEventsRequest struct { func (x *SubscribeScenarioJobEventsRequest) Reset() { *x = SubscribeScenarioJobEventsRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[128] + mi := &file_runtime_v1_ai_proto_msgTypes[131] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11327,7 +11618,7 @@ func (x *SubscribeScenarioJobEventsRequest) String() string { func (*SubscribeScenarioJobEventsRequest) ProtoMessage() {} func (x *SubscribeScenarioJobEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[128] + mi := &file_runtime_v1_ai_proto_msgTypes[131] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11340,7 +11631,7 @@ func (x *SubscribeScenarioJobEventsRequest) ProtoReflect() protoreflect.Message // Deprecated: Use SubscribeScenarioJobEventsRequest.ProtoReflect.Descriptor instead. func (*SubscribeScenarioJobEventsRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{128} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{131} } func (x *SubscribeScenarioJobEventsRequest) GetJobId() string { @@ -11359,7 +11650,7 @@ type GetScenarioArtifactsRequest struct { func (x *GetScenarioArtifactsRequest) Reset() { *x = GetScenarioArtifactsRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[129] + mi := &file_runtime_v1_ai_proto_msgTypes[132] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11371,7 +11662,7 @@ func (x *GetScenarioArtifactsRequest) String() string { func (*GetScenarioArtifactsRequest) ProtoMessage() {} func (x *GetScenarioArtifactsRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[129] + mi := &file_runtime_v1_ai_proto_msgTypes[132] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11384,7 +11675,7 @@ func (x *GetScenarioArtifactsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetScenarioArtifactsRequest.ProtoReflect.Descriptor instead. func (*GetScenarioArtifactsRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{129} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{132} } func (x *GetScenarioArtifactsRequest) GetJobId() string { @@ -11406,7 +11697,7 @@ type GetScenarioArtifactsResponse struct { func (x *GetScenarioArtifactsResponse) Reset() { *x = GetScenarioArtifactsResponse{} - mi := &file_runtime_v1_ai_proto_msgTypes[130] + mi := &file_runtime_v1_ai_proto_msgTypes[133] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11418,7 +11709,7 @@ func (x *GetScenarioArtifactsResponse) String() string { func (*GetScenarioArtifactsResponse) ProtoMessage() {} func (x *GetScenarioArtifactsResponse) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[130] + mi := &file_runtime_v1_ai_proto_msgTypes[133] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11431,7 +11722,7 @@ func (x *GetScenarioArtifactsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetScenarioArtifactsResponse.ProtoReflect.Descriptor instead. func (*GetScenarioArtifactsResponse) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{130} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{133} } func (x *GetScenarioArtifactsResponse) GetJobId() string { @@ -11473,7 +11764,7 @@ type ScenarioProfile struct { func (x *ScenarioProfile) Reset() { *x = ScenarioProfile{} - mi := &file_runtime_v1_ai_proto_msgTypes[131] + mi := &file_runtime_v1_ai_proto_msgTypes[134] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11485,7 +11776,7 @@ func (x *ScenarioProfile) String() string { func (*ScenarioProfile) ProtoMessage() {} func (x *ScenarioProfile) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[131] + mi := &file_runtime_v1_ai_proto_msgTypes[134] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11498,7 +11789,7 @@ func (x *ScenarioProfile) ProtoReflect() protoreflect.Message { // Deprecated: Use ScenarioProfile.ProtoReflect.Descriptor instead. func (*ScenarioProfile) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{131} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{134} } func (x *ScenarioProfile) GetScenarioType() ScenarioType { @@ -11531,7 +11822,7 @@ type ListScenarioProfilesRequest struct { func (x *ListScenarioProfilesRequest) Reset() { *x = ListScenarioProfilesRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[132] + mi := &file_runtime_v1_ai_proto_msgTypes[135] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11543,7 +11834,7 @@ func (x *ListScenarioProfilesRequest) String() string { func (*ListScenarioProfilesRequest) ProtoMessage() {} func (x *ListScenarioProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[132] + mi := &file_runtime_v1_ai_proto_msgTypes[135] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11556,7 +11847,7 @@ func (x *ListScenarioProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListScenarioProfilesRequest.ProtoReflect.Descriptor instead. func (*ListScenarioProfilesRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{132} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{135} } func (x *ListScenarioProfilesRequest) GetModelId() string { @@ -11575,7 +11866,7 @@ type ListScenarioProfilesResponse struct { func (x *ListScenarioProfilesResponse) Reset() { *x = ListScenarioProfilesResponse{} - mi := &file_runtime_v1_ai_proto_msgTypes[133] + mi := &file_runtime_v1_ai_proto_msgTypes[136] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11587,7 +11878,7 @@ func (x *ListScenarioProfilesResponse) String() string { func (*ListScenarioProfilesResponse) ProtoMessage() {} func (x *ListScenarioProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[133] + mi := &file_runtime_v1_ai_proto_msgTypes[136] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11600,7 +11891,7 @@ func (x *ListScenarioProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListScenarioProfilesResponse.ProtoReflect.Descriptor instead. func (*ListScenarioProfilesResponse) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{133} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{136} } func (x *ListScenarioProfilesResponse) GetProfiles() []*ScenarioProfile { @@ -11619,7 +11910,7 @@ type VideoContentImageURL struct { func (x *VideoContentImageURL) Reset() { *x = VideoContentImageURL{} - mi := &file_runtime_v1_ai_proto_msgTypes[134] + mi := &file_runtime_v1_ai_proto_msgTypes[137] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11631,7 +11922,7 @@ func (x *VideoContentImageURL) String() string { func (*VideoContentImageURL) ProtoMessage() {} func (x *VideoContentImageURL) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[134] + mi := &file_runtime_v1_ai_proto_msgTypes[137] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11644,7 +11935,7 @@ func (x *VideoContentImageURL) ProtoReflect() protoreflect.Message { // Deprecated: Use VideoContentImageURL.ProtoReflect.Descriptor instead. func (*VideoContentImageURL) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{134} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{137} } func (x *VideoContentImageURL) GetUrl() string { @@ -11663,7 +11954,7 @@ type VideoContentVideoURL struct { func (x *VideoContentVideoURL) Reset() { *x = VideoContentVideoURL{} - mi := &file_runtime_v1_ai_proto_msgTypes[135] + mi := &file_runtime_v1_ai_proto_msgTypes[138] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11675,7 +11966,7 @@ func (x *VideoContentVideoURL) String() string { func (*VideoContentVideoURL) ProtoMessage() {} func (x *VideoContentVideoURL) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[135] + mi := &file_runtime_v1_ai_proto_msgTypes[138] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11688,7 +11979,7 @@ func (x *VideoContentVideoURL) ProtoReflect() protoreflect.Message { // Deprecated: Use VideoContentVideoURL.ProtoReflect.Descriptor instead. func (*VideoContentVideoURL) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{135} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{138} } func (x *VideoContentVideoURL) GetUrl() string { @@ -11707,7 +11998,7 @@ type VideoContentAudioURL struct { func (x *VideoContentAudioURL) Reset() { *x = VideoContentAudioURL{} - mi := &file_runtime_v1_ai_proto_msgTypes[136] + mi := &file_runtime_v1_ai_proto_msgTypes[139] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11719,7 +12010,7 @@ func (x *VideoContentAudioURL) String() string { func (*VideoContentAudioURL) ProtoMessage() {} func (x *VideoContentAudioURL) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[136] + mi := &file_runtime_v1_ai_proto_msgTypes[139] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11732,7 +12023,7 @@ func (x *VideoContentAudioURL) ProtoReflect() protoreflect.Message { // Deprecated: Use VideoContentAudioURL.ProtoReflect.Descriptor instead. func (*VideoContentAudioURL) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{136} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{139} } func (x *VideoContentAudioURL) GetUrl() string { @@ -11751,7 +12042,7 @@ type VideoContentArtifactRef struct { func (x *VideoContentArtifactRef) Reset() { *x = VideoContentArtifactRef{} - mi := &file_runtime_v1_ai_proto_msgTypes[137] + mi := &file_runtime_v1_ai_proto_msgTypes[140] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11763,7 +12054,7 @@ func (x *VideoContentArtifactRef) String() string { func (*VideoContentArtifactRef) ProtoMessage() {} func (x *VideoContentArtifactRef) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[137] + mi := &file_runtime_v1_ai_proto_msgTypes[140] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11776,7 +12067,7 @@ func (x *VideoContentArtifactRef) ProtoReflect() protoreflect.Message { // Deprecated: Use VideoContentArtifactRef.ProtoReflect.Descriptor instead. func (*VideoContentArtifactRef) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{137} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{140} } func (x *VideoContentArtifactRef) GetArtifactId() string { @@ -11801,7 +12092,7 @@ type VideoContentItem struct { func (x *VideoContentItem) Reset() { *x = VideoContentItem{} - mi := &file_runtime_v1_ai_proto_msgTypes[138] + mi := &file_runtime_v1_ai_proto_msgTypes[141] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11813,7 +12104,7 @@ func (x *VideoContentItem) String() string { func (*VideoContentItem) ProtoMessage() {} func (x *VideoContentItem) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[138] + mi := &file_runtime_v1_ai_proto_msgTypes[141] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11826,7 +12117,7 @@ func (x *VideoContentItem) ProtoReflect() protoreflect.Message { // Deprecated: Use VideoContentItem.ProtoReflect.Descriptor instead. func (*VideoContentItem) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{138} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{141} } func (x *VideoContentItem) GetType() VideoContentType { @@ -11899,7 +12190,7 @@ type VideoGenerationOptions struct { func (x *VideoGenerationOptions) Reset() { *x = VideoGenerationOptions{} - mi := &file_runtime_v1_ai_proto_msgTypes[139] + mi := &file_runtime_v1_ai_proto_msgTypes[142] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11911,7 +12202,7 @@ func (x *VideoGenerationOptions) String() string { func (*VideoGenerationOptions) ProtoMessage() {} func (x *VideoGenerationOptions) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[139] + mi := &file_runtime_v1_ai_proto_msgTypes[142] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11924,7 +12215,7 @@ func (x *VideoGenerationOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use VideoGenerationOptions.ProtoReflect.Descriptor instead. func (*VideoGenerationOptions) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{139} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{142} } func (x *VideoGenerationOptions) GetResolution() string { @@ -12031,7 +12322,7 @@ type VoiceRenderHints struct { func (x *VoiceRenderHints) Reset() { *x = VoiceRenderHints{} - mi := &file_runtime_v1_ai_proto_msgTypes[140] + mi := &file_runtime_v1_ai_proto_msgTypes[143] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12043,7 +12334,7 @@ func (x *VoiceRenderHints) String() string { func (*VoiceRenderHints) ProtoMessage() {} func (x *VoiceRenderHints) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[140] + mi := &file_runtime_v1_ai_proto_msgTypes[143] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12056,7 +12347,7 @@ func (x *VoiceRenderHints) ProtoReflect() protoreflect.Message { // Deprecated: Use VoiceRenderHints.ProtoReflect.Descriptor instead. func (*VoiceRenderHints) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{140} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{143} } func (x *VoiceRenderHints) GetStability() float32 { @@ -12103,7 +12394,7 @@ type AudioChunks struct { func (x *AudioChunks) Reset() { *x = AudioChunks{} - mi := &file_runtime_v1_ai_proto_msgTypes[141] + mi := &file_runtime_v1_ai_proto_msgTypes[144] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12115,7 +12406,7 @@ func (x *AudioChunks) String() string { func (*AudioChunks) ProtoMessage() {} func (x *AudioChunks) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[141] + mi := &file_runtime_v1_ai_proto_msgTypes[144] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12128,7 +12419,7 @@ func (x *AudioChunks) ProtoReflect() protoreflect.Message { // Deprecated: Use AudioChunks.ProtoReflect.Descriptor instead. func (*AudioChunks) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{141} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{144} } func (x *AudioChunks) GetChunks() [][]byte { @@ -12152,7 +12443,7 @@ type SpeechTranscriptionAudioSource struct { func (x *SpeechTranscriptionAudioSource) Reset() { *x = SpeechTranscriptionAudioSource{} - mi := &file_runtime_v1_ai_proto_msgTypes[142] + mi := &file_runtime_v1_ai_proto_msgTypes[145] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12164,7 +12455,7 @@ func (x *SpeechTranscriptionAudioSource) String() string { func (*SpeechTranscriptionAudioSource) ProtoMessage() {} func (x *SpeechTranscriptionAudioSource) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[142] + mi := &file_runtime_v1_ai_proto_msgTypes[145] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12177,7 +12468,7 @@ func (x *SpeechTranscriptionAudioSource) ProtoReflect() protoreflect.Message { // Deprecated: Use SpeechTranscriptionAudioSource.ProtoReflect.Descriptor instead. func (*SpeechTranscriptionAudioSource) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{142} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{145} } func (x *SpeechTranscriptionAudioSource) GetSource() isSpeechTranscriptionAudioSource_Source { @@ -12247,7 +12538,7 @@ type SpeechAlignmentToken struct { func (x *SpeechAlignmentToken) Reset() { *x = SpeechAlignmentToken{} - mi := &file_runtime_v1_ai_proto_msgTypes[143] + mi := &file_runtime_v1_ai_proto_msgTypes[146] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12259,7 +12550,7 @@ func (x *SpeechAlignmentToken) String() string { func (*SpeechAlignmentToken) ProtoMessage() {} func (x *SpeechAlignmentToken) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[143] + mi := &file_runtime_v1_ai_proto_msgTypes[146] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12272,7 +12563,7 @@ func (x *SpeechAlignmentToken) ProtoReflect() protoreflect.Message { // Deprecated: Use SpeechAlignmentToken.ProtoReflect.Descriptor instead. func (*SpeechAlignmentToken) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{143} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{146} } func (x *SpeechAlignmentToken) GetToken() string { @@ -12306,7 +12597,7 @@ type SpeechAlignment struct { func (x *SpeechAlignment) Reset() { *x = SpeechAlignment{} - mi := &file_runtime_v1_ai_proto_msgTypes[144] + mi := &file_runtime_v1_ai_proto_msgTypes[147] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12318,7 +12609,7 @@ func (x *SpeechAlignment) String() string { func (*SpeechAlignment) ProtoMessage() {} func (x *SpeechAlignment) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[144] + mi := &file_runtime_v1_ai_proto_msgTypes[147] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12331,7 +12622,7 @@ func (x *SpeechAlignment) ProtoReflect() protoreflect.Message { // Deprecated: Use SpeechAlignment.ProtoReflect.Descriptor instead. func (*SpeechAlignment) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{144} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{147} } func (x *SpeechAlignment) GetUnit() SpeechAlignmentUnit { @@ -12365,7 +12656,7 @@ type ArtifactChunk struct { func (x *ArtifactChunk) Reset() { *x = ArtifactChunk{} - mi := &file_runtime_v1_ai_proto_msgTypes[145] + mi := &file_runtime_v1_ai_proto_msgTypes[148] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12377,7 +12668,7 @@ func (x *ArtifactChunk) String() string { func (*ArtifactChunk) ProtoMessage() {} func (x *ArtifactChunk) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[145] + mi := &file_runtime_v1_ai_proto_msgTypes[148] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12390,7 +12681,7 @@ func (x *ArtifactChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use ArtifactChunk.ProtoReflect.Descriptor instead. func (*ArtifactChunk) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{145} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{148} } func (x *ArtifactChunk) GetArtifactId() string { @@ -12468,7 +12759,7 @@ type UploadArtifactMetadata struct { func (x *UploadArtifactMetadata) Reset() { *x = UploadArtifactMetadata{} - mi := &file_runtime_v1_ai_proto_msgTypes[146] + mi := &file_runtime_v1_ai_proto_msgTypes[149] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12480,7 +12771,7 @@ func (x *UploadArtifactMetadata) String() string { func (*UploadArtifactMetadata) ProtoMessage() {} func (x *UploadArtifactMetadata) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[146] + mi := &file_runtime_v1_ai_proto_msgTypes[149] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12493,7 +12784,7 @@ func (x *UploadArtifactMetadata) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadArtifactMetadata.ProtoReflect.Descriptor instead. func (*UploadArtifactMetadata) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{146} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{149} } func (x *UploadArtifactMetadata) GetAppId() string { @@ -12534,7 +12825,7 @@ type UploadArtifactChunk struct { func (x *UploadArtifactChunk) Reset() { *x = UploadArtifactChunk{} - mi := &file_runtime_v1_ai_proto_msgTypes[147] + mi := &file_runtime_v1_ai_proto_msgTypes[150] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12546,7 +12837,7 @@ func (x *UploadArtifactChunk) String() string { func (*UploadArtifactChunk) ProtoMessage() {} func (x *UploadArtifactChunk) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[147] + mi := &file_runtime_v1_ai_proto_msgTypes[150] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12559,7 +12850,7 @@ func (x *UploadArtifactChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadArtifactChunk.ProtoReflect.Descriptor instead. func (*UploadArtifactChunk) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{147} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{150} } func (x *UploadArtifactChunk) GetSequence() uint64 { @@ -12589,7 +12880,7 @@ type UploadArtifactRequest struct { func (x *UploadArtifactRequest) Reset() { *x = UploadArtifactRequest{} - mi := &file_runtime_v1_ai_proto_msgTypes[148] + mi := &file_runtime_v1_ai_proto_msgTypes[151] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12601,7 +12892,7 @@ func (x *UploadArtifactRequest) String() string { func (*UploadArtifactRequest) ProtoMessage() {} func (x *UploadArtifactRequest) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[148] + mi := &file_runtime_v1_ai_proto_msgTypes[151] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12614,7 +12905,7 @@ func (x *UploadArtifactRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadArtifactRequest.ProtoReflect.Descriptor instead. func (*UploadArtifactRequest) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{148} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{151} } func (x *UploadArtifactRequest) GetPayload() isUploadArtifactRequest_Payload { @@ -12668,7 +12959,7 @@ type UploadArtifactResponse struct { func (x *UploadArtifactResponse) Reset() { *x = UploadArtifactResponse{} - mi := &file_runtime_v1_ai_proto_msgTypes[149] + mi := &file_runtime_v1_ai_proto_msgTypes[152] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12680,7 +12971,7 @@ func (x *UploadArtifactResponse) String() string { func (*UploadArtifactResponse) ProtoMessage() {} func (x *UploadArtifactResponse) ProtoReflect() protoreflect.Message { - mi := &file_runtime_v1_ai_proto_msgTypes[149] + mi := &file_runtime_v1_ai_proto_msgTypes[152] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12693,7 +12984,7 @@ func (x *UploadArtifactResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadArtifactResponse.ProtoReflect.Descriptor instead. func (*UploadArtifactResponse) Descriptor() ([]byte, []int) { - return file_runtime_v1_ai_proto_rawDescGZIP(), []int{149} + return file_runtime_v1_ai_proto_rawDescGZIP(), []int{152} } func (x *UploadArtifactResponse) GetArtifact() *ScenarioArtifact { @@ -13099,9 +13390,10 @@ const file_runtime_v1_ai_proto_rawDesc = "" + "\x05items\x18\a \x03(\v2\x1f.nimi.runtime.v1.TextOutputItemR\x05items\x12+\n" + "\x11reasoning_summary\x18\b \x01(\tR\x10reasoningSummaryJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\ftool_resultsR\x16tool_approval_requests\")\n" + "\x0fEmbeddingVector\x12\x16\n" + - "\x06values\x18\x01 \x03(\x01R\x06values\"M\n" + + "\x06values\x18\x01 \x03(\x01R\x06values\"h\n" + "\x0fTextEmbedOutput\x12:\n" + - "\avectors\x18\x01 \x03(\v2 .nimi.runtime.v1.EmbeddingVectorR\avectors\"V\n" + + "\avectors\x18\x01 \x03(\v2 .nimi.runtime.v1.EmbeddingVectorR\avectors\x12\x19\n" + + "\bspace_id\x18\x02 \x01(\tR\aspaceId\"V\n" + "\x13ImageGenerateResult\x12?\n" + "\tartifacts\x18\x01 \x03(\v2!.nimi.runtime.v1.ScenarioArtifactR\tartifacts\"V\n" + "\x13ImageFaceSwapResult\x12?\n" + @@ -13158,10 +13450,12 @@ const file_runtime_v1_ai_proto_rawDesc = "" + "\x0emodel_resolved\x18\x05 \x01(\tR\rmodelResolved\x12\x19\n" + "\btrace_id\x18\x06 \x01(\tR\atraceId\x12X\n" + "\x12ignored_extensions\x18\a \x03(\v2).nimi.runtime.v1.IgnoredScenarioExtensionR\x11ignoredExtensions\x12h\n" + - "\x18effective_input_identity\x18\t \x01(\v2..nimi.runtime.v1.LoadoutEffectiveInputIdentityR\x16effectiveInputIdentityJ\x04\b\b\x10\tR\x1aresolved_execution_binding\"F\n" + + "\x18effective_input_identity\x18\t \x01(\v2..nimi.runtime.v1.LoadoutEffectiveInputIdentityR\x16effectiveInputIdentityJ\x04\b\b\x10\tR\x1aresolved_execution_binding\"\x84\x01\n" + "\x1cLocalAppTextCandidateMessage\x12\x12\n" + "\x04role\x18\x01 \x01(\tR\x04role\x12\x12\n" + - "\x04text\x18\x02 \x01(\tR\x04text\"\xbd\x02\n" + + "\x04text\x18\x02 \x01(\tR\x04text\x12<\n" + + "\n" + + "turn_items\x18\x03 \x03(\v2\x1d.nimi.runtime.v1.TextTurnItemR\tturnItems\"\xbd\x02\n" + "$GenerateLocalAppTextCandidateRequest\x12I\n" + "\bmessages\x18\x01 \x03(\v2-.nimi.runtime.v1.LocalAppTextCandidateMessageR\bmessages\x12%\n" + "\vtemperature\x18\x02 \x01(\x02H\x00R\vtemperature\x88\x01\x01\x12\x18\n" + @@ -13213,20 +13507,26 @@ const file_runtime_v1_ai_proto_rawDesc = "" + "\bstrength\x18\x0e \x01(\x02H\x02R\bstrength\x88\x01\x01B\x04\n" + "\x02_nB\a\n" + "\x05_seedB\v\n" + - "\t_strength\"\xd6\x01\n" + + "\t_strength\"\xad\x02\n" + "\x1eExecuteLocalAppScenarioRequest\x12O\n" + "\n" + "text_embed\x18\x01 \x01(\v2..nimi.runtime.v1.LocalAppTextEmbedScenarioSpecH\x00R\ttextEmbed\x12[\n" + - "\x0eimage_generate\x18\x02 \x01(\v22.nimi.runtime.v1.LocalAppImageGenerateScenarioSpecH\x00R\rimageGenerateB\x06\n" + - "\x04spec\"U\n" + + "\x0eimage_generate\x18\x02 \x01(\v22.nimi.runtime.v1.LocalAppImageGenerateScenarioSpecH\x00R\rimageGenerate\x12U\n" + + "\rtext_generate\x18\x03 \x01(\v2..nimi.runtime.v1.StreamLocalAppTextTurnRequestH\x00R\ftextGenerateB\x06\n" + + "\x04spec\"p\n" + "\x17LocalAppTextEmbedOutput\x12:\n" + - "\avectors\x18\x01 \x03(\v2 .nimi.runtime.v1.EmbeddingVectorR\avectors\"f\n" + + "\avectors\x18\x01 \x03(\v2 .nimi.runtime.v1.EmbeddingVectorR\avectors\x12\x19\n" + + "\bspace_id\x18\x02 \x01(\tR\aspaceId\"f\n" + "\x1bLocalAppImageGenerateOutput\x12G\n" + - "\tartifacts\x18\x01 \x03(\v2).nimi.runtime.v1.LocalAppScenarioArtifactR\tartifacts\"\xe8\x01\n" + + "\tartifacts\x18\x01 \x03(\v2).nimi.runtime.v1.LocalAppScenarioArtifactR\tartifacts\"\x97\x01\n" + + "\x1aLocalAppTextGenerateOutput\x125\n" + + "\x05items\x18\x01 \x03(\v2\x1f.nimi.runtime.v1.TextOutputItemR\x05items\x12B\n" + + "\rfinish_reason\x18\x02 \x01(\x0e2\x1d.nimi.runtime.v1.FinishReasonR\ffinishReason\"\xbc\x02\n" + "\x1fExecuteLocalAppScenarioResponse\x12I\n" + "\n" + "text_embed\x18\x01 \x01(\v2(.nimi.runtime.v1.LocalAppTextEmbedOutputH\x00R\ttextEmbed\x12U\n" + - "\x0eimage_generate\x18\x02 \x01(\v2,.nimi.runtime.v1.LocalAppImageGenerateOutputH\x00R\rimageGenerate\x12\x19\n" + + "\x0eimage_generate\x18\x02 \x01(\v2,.nimi.runtime.v1.LocalAppImageGenerateOutputH\x00R\rimageGenerate\x12R\n" + + "\rtext_generate\x18\x04 \x01(\v2+.nimi.runtime.v1.LocalAppTextGenerateOutputH\x00R\ftextGenerate\x12\x19\n" + "\btrace_id\x18\x03 \x01(\tR\atraceIdB\b\n" + "\x06output\"\x8d\x04\n" + "\x1eLocalAppVideoGenerationOptions\x12\x1e\n" + @@ -13370,7 +13670,7 @@ const file_runtime_v1_ai_proto_rawDesc = "" + "\bsequence\x18\x02 \x01(\x04R\bsequence\x12\x19\n" + "\btrace_id\x18\x03 \x01(\tR\atraceId\x128\n" + "\ttimestamp\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x126\n" + - "\x03job\x18\x05 \x01(\v2$.nimi.runtime.v1.LocalAppScenarioJobR\x03job\"\xdf\x03\n" + + "\x03job\x18\x05 \x01(\v2$.nimi.runtime.v1.LocalAppScenarioJobR\x03job\"\xc6\x05\n" + "\x1dStreamLocalAppTextTurnRequest\x12I\n" + "\bmessages\x18\x01 \x03(\v2-.nimi.runtime.v1.LocalAppTextCandidateMessageR\bmessages\x12%\n" + "\vtemperature\x18\x02 \x01(\x02H\x00R\vtemperature\x88\x01\x01\x12\x18\n" + @@ -13381,16 +13681,32 @@ const file_runtime_v1_ai_proto_rawDesc = "" + "\x10presence_penalty\x18\x06 \x01(\x02H\x04R\x0fpresencePenalty\x88\x01\x01\x120\n" + "\x11frequency_penalty\x18\a \x01(\x02H\x05R\x10frequencyPenalty\x88\x01\x01\x12\x12\n" + "\x04stop\x18\b \x03(\tR\x04stop\x12\x17\n" + - "\x04seed\x18\t \x01(\x03H\x06R\x04seed\x88\x01\x01B\x0e\n" + + "\x04seed\x18\t \x01(\x03H\x06R\x04seed\x88\x01\x01\x12/\n" + + "\x05tools\x18\n" + + " \x03(\v2\x19.nimi.runtime.v1.ToolSpecR\x05tools\x12@\n" + + "\vtool_choice\x18\v \x01(\x0e2\x1f.nimi.runtime.v1.ToolChoiceModeR\n" + + "toolChoice\x12(\n" + + "\x10tool_choice_name\x18\f \x01(\tR\x0etoolChoiceName\x12H\n" + + "\x0fresponse_format\x18\r \x01(\v2\x1f.nimi.runtime.v1.ResponseFormatR\x0eresponseFormatB\x0e\n" + "\f_temperatureB\b\n" + "\x06_top_pB\r\n" + "\v_max_tokensB\b\n" + "\x06_top_kB\x13\n" + "\x11_presence_penaltyB\x14\n" + "\x12_frequency_penaltyB\a\n" + - "\x05_seed\"+\n" + + "\x05_seed\"J\n" + "\x15LocalAppTextTurnDelta\x12\x12\n" + - "\x04text\x18\x01 \x01(\tR\x04text\"_\n" + + "\x04text\x18\x01 \x01(\tR\x04text\x12\x1d\n" + + "\n" + + "item_index\x18\x02 \x01(\rR\titemIndex\"q\n" + + "\x18LocalAppTextTurnToolCall\x12\x1d\n" + + "\n" + + "item_index\x18\x01 \x01(\rR\titemIndex\x126\n" + + "\ttool_call\x18\x02 \x01(\v2\x19.nimi.runtime.v1.ToolCallR\btoolCall\"\x82\x01\n" + + "\x1aLocalAppTextTurnContinuity\x12\x1d\n" + + "\n" + + "item_index\x18\x01 \x01(\rR\titemIndex\x12E\n" + + "\acarrier\x18\x02 \x01(\v2+.nimi.runtime.v1.ReasoningContinuityCarrierR\acarrier\"_\n" + "\x19LocalAppTextTurnCompleted\x12B\n" + "\rfinish_reason\x18\x01 \x01(\x0e2\x1d.nimi.runtime.v1.FinishReasonR\ffinishReason\"\xc3\x01\n" + "\x16LocalAppTextTurnFailed\x12<\n" + @@ -13398,13 +13714,15 @@ const file_runtime_v1_ai_proto_rawDesc = "" + "reasonCode\x12\x1f\n" + "\vaction_hint\x18\x02 \x01(\tR\n" + "actionHint\x12J\n" + - "\finterruption\x18\x03 \x01(\v2&.nimi.runtime.v1.ExecutionInterruptionR\finterruption\"\xae\x02\n" + + "\finterruption\x18\x03 \x01(\v2&.nimi.runtime.v1.ExecutionInterruptionR\finterruption\"\xda\x03\n" + "\x1bStreamLocalAppTextTurnEvent\x12\x1a\n" + "\bsequence\x18\x01 \x01(\x04R\bsequence\x12\x19\n" + "\btrace_id\x18\x02 \x01(\tR\atraceId\x12>\n" + "\x05delta\x18\x03 \x01(\v2&.nimi.runtime.v1.LocalAppTextTurnDeltaH\x00R\x05delta\x12J\n" + "\tcompleted\x18\x04 \x01(\v2*.nimi.runtime.v1.LocalAppTextTurnCompletedH\x00R\tcompleted\x12A\n" + - "\x06failed\x18\x05 \x01(\v2'.nimi.runtime.v1.LocalAppTextTurnFailedH\x00R\x06failedB\t\n" + + "\x06failed\x18\x05 \x01(\v2'.nimi.runtime.v1.LocalAppTextTurnFailedH\x00R\x06failed\x12H\n" + + "\ttool_call\x18\x06 \x01(\v2).nimi.runtime.v1.LocalAppTextTurnToolCallH\x00R\btoolCall\x12`\n" + + "\x14reasoning_continuity\x18\a \x01(\v2+.nimi.runtime.v1.LocalAppTextTurnContinuityH\x00R\x13reasoningContinuityB\t\n" + "\apayload\">\n" + "\x1bReadLocalAppArtifactRequest\x12\x1f\n" + "\vartifact_id\x18\x01 \x01(\tR\n" + @@ -13867,7 +14185,7 @@ func file_runtime_v1_ai_proto_rawDescGZIP() []byte { } var file_runtime_v1_ai_proto_enumTypes = make([]protoimpl.EnumInfo, 24) -var file_runtime_v1_ai_proto_msgTypes = make([]protoimpl.MessageInfo, 152) +var file_runtime_v1_ai_proto_msgTypes = make([]protoimpl.MessageInfo, 155) var file_runtime_v1_ai_proto_goTypes = []any{ (Modal)(0), // 0: nimi.runtime.v1.Modal (ScenarioType)(0), // 1: nimi.runtime.v1.ScenarioType @@ -13976,106 +14294,109 @@ var file_runtime_v1_ai_proto_goTypes = []any{ (*ExecuteLocalAppScenarioRequest)(nil), // 104: nimi.runtime.v1.ExecuteLocalAppScenarioRequest (*LocalAppTextEmbedOutput)(nil), // 105: nimi.runtime.v1.LocalAppTextEmbedOutput (*LocalAppImageGenerateOutput)(nil), // 106: nimi.runtime.v1.LocalAppImageGenerateOutput - (*ExecuteLocalAppScenarioResponse)(nil), // 107: nimi.runtime.v1.ExecuteLocalAppScenarioResponse - (*LocalAppVideoGenerationOptions)(nil), // 108: nimi.runtime.v1.LocalAppVideoGenerationOptions - (*LocalAppVideoGenerateJobSpec)(nil), // 109: nimi.runtime.v1.LocalAppVideoGenerateJobSpec - (*LocalAppSpeechSynthesizeJobSpec)(nil), // 110: nimi.runtime.v1.LocalAppSpeechSynthesizeJobSpec - (*LocalAppSpeechTranscribeJobSpec)(nil), // 111: nimi.runtime.v1.LocalAppSpeechTranscribeJobSpec - (*LocalAppVoiceCreateJobSpec)(nil), // 112: nimi.runtime.v1.LocalAppVoiceCreateJobSpec - (*LocalAppMusicGenerateJobSpec)(nil), // 113: nimi.runtime.v1.LocalAppMusicGenerateJobSpec - (*LocalAppWorldGenerateJobSpec)(nil), // 114: nimi.runtime.v1.LocalAppWorldGenerateJobSpec - (*SubmitLocalAppScenarioJobRequest)(nil), // 115: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest - (*LocalAppScenarioJob)(nil), // 116: nimi.runtime.v1.LocalAppScenarioJob - (*LocalAppVoiceAsset)(nil), // 117: nimi.runtime.v1.LocalAppVoiceAsset - (*SubmitLocalAppScenarioJobResponse)(nil), // 118: nimi.runtime.v1.SubmitLocalAppScenarioJobResponse - (*GetLocalAppScenarioJobRequest)(nil), // 119: nimi.runtime.v1.GetLocalAppScenarioJobRequest - (*GetLocalAppScenarioJobResponse)(nil), // 120: nimi.runtime.v1.GetLocalAppScenarioJobResponse - (*CancelLocalAppScenarioJobRequest)(nil), // 121: nimi.runtime.v1.CancelLocalAppScenarioJobRequest - (*CancelLocalAppScenarioJobResponse)(nil), // 122: nimi.runtime.v1.CancelLocalAppScenarioJobResponse - (*SubscribeLocalAppScenarioJobEventsRequest)(nil), // 123: nimi.runtime.v1.SubscribeLocalAppScenarioJobEventsRequest - (*LocalAppScenarioJobEvent)(nil), // 124: nimi.runtime.v1.LocalAppScenarioJobEvent - (*StreamLocalAppTextTurnRequest)(nil), // 125: nimi.runtime.v1.StreamLocalAppTextTurnRequest - (*LocalAppTextTurnDelta)(nil), // 126: nimi.runtime.v1.LocalAppTextTurnDelta - (*LocalAppTextTurnCompleted)(nil), // 127: nimi.runtime.v1.LocalAppTextTurnCompleted - (*LocalAppTextTurnFailed)(nil), // 128: nimi.runtime.v1.LocalAppTextTurnFailed - (*StreamLocalAppTextTurnEvent)(nil), // 129: nimi.runtime.v1.StreamLocalAppTextTurnEvent - (*ReadLocalAppArtifactRequest)(nil), // 130: nimi.runtime.v1.ReadLocalAppArtifactRequest - (*ReadLocalAppArtifactResponse)(nil), // 131: nimi.runtime.v1.ReadLocalAppArtifactResponse - (*UploadLocalAppArtifactRequest)(nil), // 132: nimi.runtime.v1.UploadLocalAppArtifactRequest - (*UploadLocalAppArtifactResponse)(nil), // 133: nimi.runtime.v1.UploadLocalAppArtifactResponse - (*ListLocalAppVoiceAssetsRequest)(nil), // 134: nimi.runtime.v1.ListLocalAppVoiceAssetsRequest - (*ListLocalAppVoiceAssetsResponse)(nil), // 135: nimi.runtime.v1.ListLocalAppVoiceAssetsResponse - (*StreamScenarioRequest)(nil), // 136: nimi.runtime.v1.StreamScenarioRequest - (*ScenarioStreamStarted)(nil), // 137: nimi.runtime.v1.ScenarioStreamStarted - (*ArtifactStreamDelta)(nil), // 138: nimi.runtime.v1.ArtifactStreamDelta - (*ScenarioStreamDelta)(nil), // 139: nimi.runtime.v1.ScenarioStreamDelta - (*ScenarioStreamCompleted)(nil), // 140: nimi.runtime.v1.ScenarioStreamCompleted - (*ScenarioStreamFailed)(nil), // 141: nimi.runtime.v1.ScenarioStreamFailed - (*StreamScenarioEvent)(nil), // 142: nimi.runtime.v1.StreamScenarioEvent - (*ScenarioArtifact)(nil), // 143: nimi.runtime.v1.ScenarioArtifact - (*ScenarioJob)(nil), // 144: nimi.runtime.v1.ScenarioJob - (*SubmitScenarioJobRequest)(nil), // 145: nimi.runtime.v1.SubmitScenarioJobRequest - (*SubmitScenarioJobResponse)(nil), // 146: nimi.runtime.v1.SubmitScenarioJobResponse - (*GetScenarioJobRequest)(nil), // 147: nimi.runtime.v1.GetScenarioJobRequest - (*GetScenarioJobResponse)(nil), // 148: nimi.runtime.v1.GetScenarioJobResponse - (*CancelScenarioJobRequest)(nil), // 149: nimi.runtime.v1.CancelScenarioJobRequest - (*CancelScenarioJobResponse)(nil), // 150: nimi.runtime.v1.CancelScenarioJobResponse - (*ScenarioJobEvent)(nil), // 151: nimi.runtime.v1.ScenarioJobEvent - (*SubscribeScenarioJobEventsRequest)(nil), // 152: nimi.runtime.v1.SubscribeScenarioJobEventsRequest - (*GetScenarioArtifactsRequest)(nil), // 153: nimi.runtime.v1.GetScenarioArtifactsRequest - (*GetScenarioArtifactsResponse)(nil), // 154: nimi.runtime.v1.GetScenarioArtifactsResponse - (*ScenarioProfile)(nil), // 155: nimi.runtime.v1.ScenarioProfile - (*ListScenarioProfilesRequest)(nil), // 156: nimi.runtime.v1.ListScenarioProfilesRequest - (*ListScenarioProfilesResponse)(nil), // 157: nimi.runtime.v1.ListScenarioProfilesResponse - (*VideoContentImageURL)(nil), // 158: nimi.runtime.v1.VideoContentImageURL - (*VideoContentVideoURL)(nil), // 159: nimi.runtime.v1.VideoContentVideoURL - (*VideoContentAudioURL)(nil), // 160: nimi.runtime.v1.VideoContentAudioURL - (*VideoContentArtifactRef)(nil), // 161: nimi.runtime.v1.VideoContentArtifactRef - (*VideoContentItem)(nil), // 162: nimi.runtime.v1.VideoContentItem - (*VideoGenerationOptions)(nil), // 163: nimi.runtime.v1.VideoGenerationOptions - (*VoiceRenderHints)(nil), // 164: nimi.runtime.v1.VoiceRenderHints - (*AudioChunks)(nil), // 165: nimi.runtime.v1.AudioChunks - (*SpeechTranscriptionAudioSource)(nil), // 166: nimi.runtime.v1.SpeechTranscriptionAudioSource - (*SpeechAlignmentToken)(nil), // 167: nimi.runtime.v1.SpeechAlignmentToken - (*SpeechAlignment)(nil), // 168: nimi.runtime.v1.SpeechAlignment - (*ArtifactChunk)(nil), // 169: nimi.runtime.v1.ArtifactChunk - (*UploadArtifactMetadata)(nil), // 170: nimi.runtime.v1.UploadArtifactMetadata - (*UploadArtifactChunk)(nil), // 171: nimi.runtime.v1.UploadArtifactChunk - (*UploadArtifactRequest)(nil), // 172: nimi.runtime.v1.UploadArtifactRequest - (*UploadArtifactResponse)(nil), // 173: nimi.runtime.v1.UploadArtifactResponse - nil, // 174: nimi.runtime.v1.WorldGenerateResult.SpzUrlsEntry - nil, // 175: nimi.runtime.v1.SubmitScenarioJobRequest.LabelsEntry - (*structpb.Struct)(nil), // 176: google.protobuf.Struct - (ToolSpecKind)(0), // 177: nimi.runtime.v1.ToolSpecKind - (*structpb.Value)(nil), // 178: google.protobuf.Value - (ToolChoiceMode)(0), // 179: nimi.runtime.v1.ToolChoiceMode - (*VoiceReference)(nil), // 180: nimi.runtime.v1.VoiceReference - (*VoiceV2VInput)(nil), // 181: nimi.runtime.v1.VoiceV2VInput - (*VoiceT2VInput)(nil), // 182: nimi.runtime.v1.VoiceT2VInput - (ReasonCode)(0), // 183: nimi.runtime.v1.ReasonCode - (*UsageStats)(nil), // 184: nimi.runtime.v1.UsageStats - (*LoadoutEffectiveInputIdentity)(nil), // 185: nimi.runtime.v1.LoadoutEffectiveInputIdentity - (*timestamppb.Timestamp)(nil), // 186: google.protobuf.Timestamp - (VoiceAssetStatus)(0), // 187: nimi.runtime.v1.VoiceAssetStatus - (VoiceCreationSource)(0), // 188: nimi.runtime.v1.VoiceCreationSource - (VoiceOutputMode)(0), // 189: nimi.runtime.v1.VoiceOutputMode - (*VoiceAsset)(nil), // 190: nimi.runtime.v1.VoiceAsset - (*GetAppAIConfigRequest)(nil), // 191: nimi.runtime.v1.GetAppAIConfigRequest - (*OverwriteAppAIConfigRequest)(nil), // 192: nimi.runtime.v1.OverwriteAppAIConfigRequest - (*ListAppAIConfigOptionsRequest)(nil), // 193: nimi.runtime.v1.ListAppAIConfigOptionsRequest - (*GetVoiceAssetRequest)(nil), // 194: nimi.runtime.v1.GetVoiceAssetRequest - (*ListVoiceAssetsRequest)(nil), // 195: nimi.runtime.v1.ListVoiceAssetsRequest - (*DeleteVoiceAssetRequest)(nil), // 196: nimi.runtime.v1.DeleteVoiceAssetRequest - (*ListPresetVoicesRequest)(nil), // 197: nimi.runtime.v1.ListPresetVoicesRequest - (*PeekSchedulingRequest)(nil), // 198: nimi.runtime.v1.PeekSchedulingRequest - (*GetAppAIConfigResponse)(nil), // 199: nimi.runtime.v1.GetAppAIConfigResponse - (*OverwriteAppAIConfigResponse)(nil), // 200: nimi.runtime.v1.OverwriteAppAIConfigResponse - (*ListAppAIConfigOptionsResponse)(nil), // 201: nimi.runtime.v1.ListAppAIConfigOptionsResponse - (*GetVoiceAssetResponse)(nil), // 202: nimi.runtime.v1.GetVoiceAssetResponse - (*ListVoiceAssetsResponse)(nil), // 203: nimi.runtime.v1.ListVoiceAssetsResponse - (*DeleteVoiceAssetResponse)(nil), // 204: nimi.runtime.v1.DeleteVoiceAssetResponse - (*ListPresetVoicesResponse)(nil), // 205: nimi.runtime.v1.ListPresetVoicesResponse - (*PeekSchedulingResponse)(nil), // 206: nimi.runtime.v1.PeekSchedulingResponse + (*LocalAppTextGenerateOutput)(nil), // 107: nimi.runtime.v1.LocalAppTextGenerateOutput + (*ExecuteLocalAppScenarioResponse)(nil), // 108: nimi.runtime.v1.ExecuteLocalAppScenarioResponse + (*LocalAppVideoGenerationOptions)(nil), // 109: nimi.runtime.v1.LocalAppVideoGenerationOptions + (*LocalAppVideoGenerateJobSpec)(nil), // 110: nimi.runtime.v1.LocalAppVideoGenerateJobSpec + (*LocalAppSpeechSynthesizeJobSpec)(nil), // 111: nimi.runtime.v1.LocalAppSpeechSynthesizeJobSpec + (*LocalAppSpeechTranscribeJobSpec)(nil), // 112: nimi.runtime.v1.LocalAppSpeechTranscribeJobSpec + (*LocalAppVoiceCreateJobSpec)(nil), // 113: nimi.runtime.v1.LocalAppVoiceCreateJobSpec + (*LocalAppMusicGenerateJobSpec)(nil), // 114: nimi.runtime.v1.LocalAppMusicGenerateJobSpec + (*LocalAppWorldGenerateJobSpec)(nil), // 115: nimi.runtime.v1.LocalAppWorldGenerateJobSpec + (*SubmitLocalAppScenarioJobRequest)(nil), // 116: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest + (*LocalAppScenarioJob)(nil), // 117: nimi.runtime.v1.LocalAppScenarioJob + (*LocalAppVoiceAsset)(nil), // 118: nimi.runtime.v1.LocalAppVoiceAsset + (*SubmitLocalAppScenarioJobResponse)(nil), // 119: nimi.runtime.v1.SubmitLocalAppScenarioJobResponse + (*GetLocalAppScenarioJobRequest)(nil), // 120: nimi.runtime.v1.GetLocalAppScenarioJobRequest + (*GetLocalAppScenarioJobResponse)(nil), // 121: nimi.runtime.v1.GetLocalAppScenarioJobResponse + (*CancelLocalAppScenarioJobRequest)(nil), // 122: nimi.runtime.v1.CancelLocalAppScenarioJobRequest + (*CancelLocalAppScenarioJobResponse)(nil), // 123: nimi.runtime.v1.CancelLocalAppScenarioJobResponse + (*SubscribeLocalAppScenarioJobEventsRequest)(nil), // 124: nimi.runtime.v1.SubscribeLocalAppScenarioJobEventsRequest + (*LocalAppScenarioJobEvent)(nil), // 125: nimi.runtime.v1.LocalAppScenarioJobEvent + (*StreamLocalAppTextTurnRequest)(nil), // 126: nimi.runtime.v1.StreamLocalAppTextTurnRequest + (*LocalAppTextTurnDelta)(nil), // 127: nimi.runtime.v1.LocalAppTextTurnDelta + (*LocalAppTextTurnToolCall)(nil), // 128: nimi.runtime.v1.LocalAppTextTurnToolCall + (*LocalAppTextTurnContinuity)(nil), // 129: nimi.runtime.v1.LocalAppTextTurnContinuity + (*LocalAppTextTurnCompleted)(nil), // 130: nimi.runtime.v1.LocalAppTextTurnCompleted + (*LocalAppTextTurnFailed)(nil), // 131: nimi.runtime.v1.LocalAppTextTurnFailed + (*StreamLocalAppTextTurnEvent)(nil), // 132: nimi.runtime.v1.StreamLocalAppTextTurnEvent + (*ReadLocalAppArtifactRequest)(nil), // 133: nimi.runtime.v1.ReadLocalAppArtifactRequest + (*ReadLocalAppArtifactResponse)(nil), // 134: nimi.runtime.v1.ReadLocalAppArtifactResponse + (*UploadLocalAppArtifactRequest)(nil), // 135: nimi.runtime.v1.UploadLocalAppArtifactRequest + (*UploadLocalAppArtifactResponse)(nil), // 136: nimi.runtime.v1.UploadLocalAppArtifactResponse + (*ListLocalAppVoiceAssetsRequest)(nil), // 137: nimi.runtime.v1.ListLocalAppVoiceAssetsRequest + (*ListLocalAppVoiceAssetsResponse)(nil), // 138: nimi.runtime.v1.ListLocalAppVoiceAssetsResponse + (*StreamScenarioRequest)(nil), // 139: nimi.runtime.v1.StreamScenarioRequest + (*ScenarioStreamStarted)(nil), // 140: nimi.runtime.v1.ScenarioStreamStarted + (*ArtifactStreamDelta)(nil), // 141: nimi.runtime.v1.ArtifactStreamDelta + (*ScenarioStreamDelta)(nil), // 142: nimi.runtime.v1.ScenarioStreamDelta + (*ScenarioStreamCompleted)(nil), // 143: nimi.runtime.v1.ScenarioStreamCompleted + (*ScenarioStreamFailed)(nil), // 144: nimi.runtime.v1.ScenarioStreamFailed + (*StreamScenarioEvent)(nil), // 145: nimi.runtime.v1.StreamScenarioEvent + (*ScenarioArtifact)(nil), // 146: nimi.runtime.v1.ScenarioArtifact + (*ScenarioJob)(nil), // 147: nimi.runtime.v1.ScenarioJob + (*SubmitScenarioJobRequest)(nil), // 148: nimi.runtime.v1.SubmitScenarioJobRequest + (*SubmitScenarioJobResponse)(nil), // 149: nimi.runtime.v1.SubmitScenarioJobResponse + (*GetScenarioJobRequest)(nil), // 150: nimi.runtime.v1.GetScenarioJobRequest + (*GetScenarioJobResponse)(nil), // 151: nimi.runtime.v1.GetScenarioJobResponse + (*CancelScenarioJobRequest)(nil), // 152: nimi.runtime.v1.CancelScenarioJobRequest + (*CancelScenarioJobResponse)(nil), // 153: nimi.runtime.v1.CancelScenarioJobResponse + (*ScenarioJobEvent)(nil), // 154: nimi.runtime.v1.ScenarioJobEvent + (*SubscribeScenarioJobEventsRequest)(nil), // 155: nimi.runtime.v1.SubscribeScenarioJobEventsRequest + (*GetScenarioArtifactsRequest)(nil), // 156: nimi.runtime.v1.GetScenarioArtifactsRequest + (*GetScenarioArtifactsResponse)(nil), // 157: nimi.runtime.v1.GetScenarioArtifactsResponse + (*ScenarioProfile)(nil), // 158: nimi.runtime.v1.ScenarioProfile + (*ListScenarioProfilesRequest)(nil), // 159: nimi.runtime.v1.ListScenarioProfilesRequest + (*ListScenarioProfilesResponse)(nil), // 160: nimi.runtime.v1.ListScenarioProfilesResponse + (*VideoContentImageURL)(nil), // 161: nimi.runtime.v1.VideoContentImageURL + (*VideoContentVideoURL)(nil), // 162: nimi.runtime.v1.VideoContentVideoURL + (*VideoContentAudioURL)(nil), // 163: nimi.runtime.v1.VideoContentAudioURL + (*VideoContentArtifactRef)(nil), // 164: nimi.runtime.v1.VideoContentArtifactRef + (*VideoContentItem)(nil), // 165: nimi.runtime.v1.VideoContentItem + (*VideoGenerationOptions)(nil), // 166: nimi.runtime.v1.VideoGenerationOptions + (*VoiceRenderHints)(nil), // 167: nimi.runtime.v1.VoiceRenderHints + (*AudioChunks)(nil), // 168: nimi.runtime.v1.AudioChunks + (*SpeechTranscriptionAudioSource)(nil), // 169: nimi.runtime.v1.SpeechTranscriptionAudioSource + (*SpeechAlignmentToken)(nil), // 170: nimi.runtime.v1.SpeechAlignmentToken + (*SpeechAlignment)(nil), // 171: nimi.runtime.v1.SpeechAlignment + (*ArtifactChunk)(nil), // 172: nimi.runtime.v1.ArtifactChunk + (*UploadArtifactMetadata)(nil), // 173: nimi.runtime.v1.UploadArtifactMetadata + (*UploadArtifactChunk)(nil), // 174: nimi.runtime.v1.UploadArtifactChunk + (*UploadArtifactRequest)(nil), // 175: nimi.runtime.v1.UploadArtifactRequest + (*UploadArtifactResponse)(nil), // 176: nimi.runtime.v1.UploadArtifactResponse + nil, // 177: nimi.runtime.v1.WorldGenerateResult.SpzUrlsEntry + nil, // 178: nimi.runtime.v1.SubmitScenarioJobRequest.LabelsEntry + (*structpb.Struct)(nil), // 179: google.protobuf.Struct + (ToolSpecKind)(0), // 180: nimi.runtime.v1.ToolSpecKind + (*structpb.Value)(nil), // 181: google.protobuf.Value + (ToolChoiceMode)(0), // 182: nimi.runtime.v1.ToolChoiceMode + (*VoiceReference)(nil), // 183: nimi.runtime.v1.VoiceReference + (*VoiceV2VInput)(nil), // 184: nimi.runtime.v1.VoiceV2VInput + (*VoiceT2VInput)(nil), // 185: nimi.runtime.v1.VoiceT2VInput + (ReasonCode)(0), // 186: nimi.runtime.v1.ReasonCode + (*UsageStats)(nil), // 187: nimi.runtime.v1.UsageStats + (*LoadoutEffectiveInputIdentity)(nil), // 188: nimi.runtime.v1.LoadoutEffectiveInputIdentity + (*timestamppb.Timestamp)(nil), // 189: google.protobuf.Timestamp + (VoiceAssetStatus)(0), // 190: nimi.runtime.v1.VoiceAssetStatus + (VoiceCreationSource)(0), // 191: nimi.runtime.v1.VoiceCreationSource + (VoiceOutputMode)(0), // 192: nimi.runtime.v1.VoiceOutputMode + (*VoiceAsset)(nil), // 193: nimi.runtime.v1.VoiceAsset + (*GetAppAIConfigRequest)(nil), // 194: nimi.runtime.v1.GetAppAIConfigRequest + (*OverwriteAppAIConfigRequest)(nil), // 195: nimi.runtime.v1.OverwriteAppAIConfigRequest + (*ListAppAIConfigOptionsRequest)(nil), // 196: nimi.runtime.v1.ListAppAIConfigOptionsRequest + (*GetVoiceAssetRequest)(nil), // 197: nimi.runtime.v1.GetVoiceAssetRequest + (*ListVoiceAssetsRequest)(nil), // 198: nimi.runtime.v1.ListVoiceAssetsRequest + (*DeleteVoiceAssetRequest)(nil), // 199: nimi.runtime.v1.DeleteVoiceAssetRequest + (*ListPresetVoicesRequest)(nil), // 200: nimi.runtime.v1.ListPresetVoicesRequest + (*PeekSchedulingRequest)(nil), // 201: nimi.runtime.v1.PeekSchedulingRequest + (*GetAppAIConfigResponse)(nil), // 202: nimi.runtime.v1.GetAppAIConfigResponse + (*OverwriteAppAIConfigResponse)(nil), // 203: nimi.runtime.v1.OverwriteAppAIConfigResponse + (*ListAppAIConfigOptionsResponse)(nil), // 204: nimi.runtime.v1.ListAppAIConfigOptionsResponse + (*GetVoiceAssetResponse)(nil), // 205: nimi.runtime.v1.GetVoiceAssetResponse + (*ListVoiceAssetsResponse)(nil), // 206: nimi.runtime.v1.ListVoiceAssetsResponse + (*DeleteVoiceAssetResponse)(nil), // 207: nimi.runtime.v1.DeleteVoiceAssetResponse + (*ListPresetVoicesResponse)(nil), // 208: nimi.runtime.v1.ListPresetVoicesResponse + (*PeekSchedulingResponse)(nil), // 209: nimi.runtime.v1.PeekSchedulingResponse } var file_runtime_v1_ai_proto_depIdxs = []int32{ 8, // 0: nimi.runtime.v1.ExecutionInterruption.cause:type_name -> nimi.runtime.v1.ExecutionInterruptionCause @@ -14085,15 +14406,15 @@ var file_runtime_v1_ai_proto_depIdxs = []int32{ 16, // 4: nimi.runtime.v1.ChatContentPart.type:type_name -> nimi.runtime.v1.ChatContentPartType 26, // 5: nimi.runtime.v1.ChatContentPart.image_url:type_name -> nimi.runtime.v1.ChatContentImageURL 27, // 6: nimi.runtime.v1.ChatContentPart.artifact_ref:type_name -> nimi.runtime.v1.ChatContentArtifactRef - 176, // 7: nimi.runtime.v1.ToolSpec.input_schema:type_name -> google.protobuf.Struct - 177, // 8: nimi.runtime.v1.ToolSpec.kind:type_name -> nimi.runtime.v1.ToolSpecKind - 176, // 9: nimi.runtime.v1.ToolSpec.provider_args:type_name -> google.protobuf.Struct - 176, // 10: nimi.runtime.v1.ToolSpec.provider_metadata:type_name -> google.protobuf.Struct + 179, // 7: nimi.runtime.v1.ToolSpec.input_schema:type_name -> google.protobuf.Struct + 180, // 8: nimi.runtime.v1.ToolSpec.kind:type_name -> nimi.runtime.v1.ToolSpecKind + 179, // 9: nimi.runtime.v1.ToolSpec.provider_args:type_name -> google.protobuf.Struct + 179, // 10: nimi.runtime.v1.ToolSpec.provider_metadata:type_name -> google.protobuf.Struct 17, // 11: nimi.runtime.v1.ResponseFormat.kind:type_name -> nimi.runtime.v1.ResponseFormatKind - 176, // 12: nimi.runtime.v1.ResponseFormat.json_schema:type_name -> google.protobuf.Struct - 176, // 13: nimi.runtime.v1.ToolCall.provider_metadata:type_name -> google.protobuf.Struct - 178, // 14: nimi.runtime.v1.ToolResult.result:type_name -> google.protobuf.Value - 176, // 15: nimi.runtime.v1.ToolResult.provider_metadata:type_name -> google.protobuf.Struct + 179, // 12: nimi.runtime.v1.ResponseFormat.json_schema:type_name -> google.protobuf.Struct + 179, // 13: nimi.runtime.v1.ToolCall.provider_metadata:type_name -> google.protobuf.Struct + 181, // 14: nimi.runtime.v1.ToolResult.result:type_name -> google.protobuf.Value + 179, // 15: nimi.runtime.v1.ToolResult.provider_metadata:type_name -> google.protobuf.Struct 33, // 16: nimi.runtime.v1.TextOutputItem.text:type_name -> nimi.runtime.v1.TextOutputText 34, // 17: nimi.runtime.v1.TextOutputItem.reasoning_summary:type_name -> nimi.runtime.v1.ReasoningSummary 31, // 18: nimi.runtime.v1.TextOutputItem.tool_call:type_name -> nimi.runtime.v1.ToolCall @@ -14105,26 +14426,26 @@ var file_runtime_v1_ai_proto_depIdxs = []int32{ 31, // 24: nimi.runtime.v1.TextOutputItemDelta.tool_call:type_name -> nimi.runtime.v1.ToolCall 35, // 25: nimi.runtime.v1.TextOutputItemDelta.reasoning_continuity:type_name -> nimi.runtime.v1.ReasoningContinuityCarrier 18, // 26: nimi.runtime.v1.TextSource.source_type:type_name -> nimi.runtime.v1.TextSourceType - 176, // 27: nimi.runtime.v1.TextSource.provider_metadata:type_name -> google.protobuf.Struct - 178, // 28: nimi.runtime.v1.RawChunk.value:type_name -> google.protobuf.Value - 176, // 29: nimi.runtime.v1.ScenarioExtension.payload:type_name -> google.protobuf.Struct + 179, // 27: nimi.runtime.v1.TextSource.provider_metadata:type_name -> google.protobuf.Struct + 181, // 28: nimi.runtime.v1.RawChunk.value:type_name -> google.protobuf.Value + 179, // 29: nimi.runtime.v1.ScenarioExtension.payload:type_name -> google.protobuf.Struct 5, // 30: nimi.runtime.v1.ReasoningConfig.activation:type_name -> nimi.runtime.v1.ReasoningActivation 6, // 31: nimi.runtime.v1.ReasoningConfig.effort:type_name -> nimi.runtime.v1.ReasoningEffort 7, // 32: nimi.runtime.v1.ReasoningConfig.presentation:type_name -> nimi.runtime.v1.ReasoningPresentation 25, // 33: nimi.runtime.v1.TextGenerateScenarioSpec.input:type_name -> nimi.runtime.v1.ChatMessage 29, // 34: nimi.runtime.v1.TextGenerateScenarioSpec.tools:type_name -> nimi.runtime.v1.ToolSpec 46, // 35: nimi.runtime.v1.TextGenerateScenarioSpec.reasoning:type_name -> nimi.runtime.v1.ReasoningConfig - 179, // 36: nimi.runtime.v1.TextGenerateScenarioSpec.tool_choice:type_name -> nimi.runtime.v1.ToolChoiceMode + 182, // 36: nimi.runtime.v1.TextGenerateScenarioSpec.tool_choice:type_name -> nimi.runtime.v1.ToolChoiceMode 30, // 37: nimi.runtime.v1.TextGenerateScenarioSpec.response_format:type_name -> nimi.runtime.v1.ResponseFormat 11, // 38: nimi.runtime.v1.VideoGenerateScenarioSpec.mode:type_name -> nimi.runtime.v1.VideoMode - 162, // 39: nimi.runtime.v1.VideoGenerateScenarioSpec.content:type_name -> nimi.runtime.v1.VideoContentItem - 163, // 40: nimi.runtime.v1.VideoGenerateScenarioSpec.options:type_name -> nimi.runtime.v1.VideoGenerationOptions - 180, // 41: nimi.runtime.v1.SpeechSynthesizeScenarioSpec.voice_ref:type_name -> nimi.runtime.v1.VoiceReference + 165, // 39: nimi.runtime.v1.VideoGenerateScenarioSpec.content:type_name -> nimi.runtime.v1.VideoContentItem + 166, // 40: nimi.runtime.v1.VideoGenerateScenarioSpec.options:type_name -> nimi.runtime.v1.VideoGenerationOptions + 183, // 41: nimi.runtime.v1.SpeechSynthesizeScenarioSpec.voice_ref:type_name -> nimi.runtime.v1.VoiceReference 14, // 42: nimi.runtime.v1.SpeechSynthesizeScenarioSpec.timing_mode:type_name -> nimi.runtime.v1.SpeechTimingMode - 164, // 43: nimi.runtime.v1.SpeechSynthesizeScenarioSpec.voice_render_hints:type_name -> nimi.runtime.v1.VoiceRenderHints - 166, // 44: nimi.runtime.v1.SpeechTranscribeScenarioSpec.audio_source:type_name -> nimi.runtime.v1.SpeechTranscriptionAudioSource - 181, // 45: nimi.runtime.v1.VoiceCreateScenarioSpec.reference_audio:type_name -> nimi.runtime.v1.VoiceV2VInput - 182, // 46: nimi.runtime.v1.VoiceCreateScenarioSpec.text_description:type_name -> nimi.runtime.v1.VoiceT2VInput + 167, // 43: nimi.runtime.v1.SpeechSynthesizeScenarioSpec.voice_render_hints:type_name -> nimi.runtime.v1.VoiceRenderHints + 169, // 44: nimi.runtime.v1.SpeechTranscribeScenarioSpec.audio_source:type_name -> nimi.runtime.v1.SpeechTranscriptionAudioSource + 184, // 45: nimi.runtime.v1.VoiceCreateScenarioSpec.reference_audio:type_name -> nimi.runtime.v1.VoiceV2VInput + 185, // 46: nimi.runtime.v1.VoiceCreateScenarioSpec.text_description:type_name -> nimi.runtime.v1.VoiceT2VInput 55, // 47: nimi.runtime.v1.WorldGenerateImagePrompt.content:type_name -> nimi.runtime.v1.WorldGenerateAssetSource 55, // 48: nimi.runtime.v1.WorldGenerateMultiImageReference.content:type_name -> nimi.runtime.v1.WorldGenerateAssetSource 57, // 49: nimi.runtime.v1.WorldGenerateMultiImagePrompt.images:type_name -> nimi.runtime.v1.WorldGenerateMultiImageReference @@ -14135,8 +14456,8 @@ var file_runtime_v1_ai_proto_depIdxs = []int32{ 21, // 54: nimi.runtime.v1.AiVideoSessionFormat.pixel_format:type_name -> nimi.runtime.v1.AiVideoPixelFormat 62, // 55: nimi.runtime.v1.OpenVideoSessionRequest.format:type_name -> nimi.runtime.v1.AiVideoSessionFormat 62, // 56: nimi.runtime.v1.OpenVideoSessionResponse.format:type_name -> nimi.runtime.v1.AiVideoSessionFormat - 183, // 57: nimi.runtime.v1.AiVideoFrameDisposition.reason_code:type_name -> nimi.runtime.v1.ReasonCode - 183, // 58: nimi.runtime.v1.AiVideoSessionTerminal.reason_code:type_name -> nimi.runtime.v1.ReasonCode + 186, // 57: nimi.runtime.v1.AiVideoFrameDisposition.reason_code:type_name -> nimi.runtime.v1.ReasonCode + 186, // 58: nimi.runtime.v1.AiVideoSessionTerminal.reason_code:type_name -> nimi.runtime.v1.ReasonCode 68, // 59: nimi.runtime.v1.AiVideoSessionResult.transformed:type_name -> nimi.runtime.v1.AiVideoTransformedFrame 69, // 60: nimi.runtime.v1.AiVideoSessionResult.no_target_face:type_name -> nimi.runtime.v1.AiVideoFrameDisposition 69, // 61: nimi.runtime.v1.AiVideoSessionResult.input_dropped:type_name -> nimi.runtime.v1.AiVideoFrameDisposition @@ -14170,17 +14491,17 @@ var file_runtime_v1_ai_proto_depIdxs = []int32{ 42, // 89: nimi.runtime.v1.TextGenerateOutput.raw_chunks:type_name -> nimi.runtime.v1.RawChunk 36, // 90: nimi.runtime.v1.TextGenerateOutput.items:type_name -> nimi.runtime.v1.TextOutputItem 85, // 91: nimi.runtime.v1.TextEmbedOutput.vectors:type_name -> nimi.runtime.v1.EmbeddingVector - 143, // 92: nimi.runtime.v1.ImageGenerateResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact - 143, // 93: nimi.runtime.v1.ImageFaceSwapResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact - 143, // 94: nimi.runtime.v1.VideoFaceSwapResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact + 146, // 92: nimi.runtime.v1.ImageGenerateResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact + 146, // 93: nimi.runtime.v1.ImageFaceSwapResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact + 146, // 94: nimi.runtime.v1.VideoFaceSwapResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact 76, // 95: nimi.runtime.v1.VideoFaceSwapResult.summary:type_name -> nimi.runtime.v1.VideoFaceSwapSummary - 143, // 96: nimi.runtime.v1.VideoGenerateResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact - 143, // 97: nimi.runtime.v1.SpeechSynthesizeResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact - 143, // 98: nimi.runtime.v1.SpeechTranscribeResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact - 143, // 99: nimi.runtime.v1.MusicGenerateResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact - 174, // 100: nimi.runtime.v1.WorldGenerateResult.spz_urls:type_name -> nimi.runtime.v1.WorldGenerateResult.SpzUrlsEntry + 146, // 96: nimi.runtime.v1.VideoGenerateResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact + 146, // 97: nimi.runtime.v1.SpeechSynthesizeResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact + 146, // 98: nimi.runtime.v1.SpeechTranscribeResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact + 146, // 99: nimi.runtime.v1.MusicGenerateResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact + 177, // 100: nimi.runtime.v1.WorldGenerateResult.spz_urls:type_name -> nimi.runtime.v1.WorldGenerateResult.SpzUrlsEntry 94, // 101: nimi.runtime.v1.WorldGenerateResult.semantics_metadata:type_name -> nimi.runtime.v1.WorldGenerateSemanticsMetadata - 143, // 102: nimi.runtime.v1.WorldGenerateResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact + 146, // 102: nimi.runtime.v1.WorldGenerateResult.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact 84, // 103: nimi.runtime.v1.ScenarioOutput.text_generate:type_name -> nimi.runtime.v1.TextGenerateOutput 86, // 104: nimi.runtime.v1.ScenarioOutput.text_embed:type_name -> nimi.runtime.v1.TextEmbedOutput 87, // 105: nimi.runtime.v1.ScenarioOutput.image_generate:type_name -> nimi.runtime.v1.ImageGenerateResult @@ -14193,209 +14514,221 @@ var file_runtime_v1_ai_proto_depIdxs = []int32{ 89, // 112: nimi.runtime.v1.ScenarioOutput.video_face_swap:type_name -> nimi.runtime.v1.VideoFaceSwapResult 96, // 113: nimi.runtime.v1.ExecuteScenarioResponse.output:type_name -> nimi.runtime.v1.ScenarioOutput 4, // 114: nimi.runtime.v1.ExecuteScenarioResponse.finish_reason:type_name -> nimi.runtime.v1.FinishReason - 184, // 115: nimi.runtime.v1.ExecuteScenarioResponse.usage:type_name -> nimi.runtime.v1.UsageStats + 187, // 115: nimi.runtime.v1.ExecuteScenarioResponse.usage:type_name -> nimi.runtime.v1.UsageStats 3, // 116: nimi.runtime.v1.ExecuteScenarioResponse.route_decision:type_name -> nimi.runtime.v1.RoutePolicy 45, // 117: nimi.runtime.v1.ExecuteScenarioResponse.ignored_extensions:type_name -> nimi.runtime.v1.IgnoredScenarioExtension - 185, // 118: nimi.runtime.v1.ExecuteScenarioResponse.effective_input_identity:type_name -> nimi.runtime.v1.LoadoutEffectiveInputIdentity - 98, // 119: nimi.runtime.v1.GenerateLocalAppTextCandidateRequest.messages:type_name -> nimi.runtime.v1.LocalAppTextCandidateMessage - 4, // 120: nimi.runtime.v1.GenerateLocalAppTextCandidateResponse.finish_reason:type_name -> nimi.runtime.v1.FinishReason - 102, // 121: nimi.runtime.v1.ExecuteLocalAppScenarioRequest.text_embed:type_name -> nimi.runtime.v1.LocalAppTextEmbedScenarioSpec - 103, // 122: nimi.runtime.v1.ExecuteLocalAppScenarioRequest.image_generate:type_name -> nimi.runtime.v1.LocalAppImageGenerateScenarioSpec - 85, // 123: nimi.runtime.v1.LocalAppTextEmbedOutput.vectors:type_name -> nimi.runtime.v1.EmbeddingVector - 101, // 124: nimi.runtime.v1.LocalAppImageGenerateOutput.artifacts:type_name -> nimi.runtime.v1.LocalAppScenarioArtifact - 105, // 125: nimi.runtime.v1.ExecuteLocalAppScenarioResponse.text_embed:type_name -> nimi.runtime.v1.LocalAppTextEmbedOutput - 106, // 126: nimi.runtime.v1.ExecuteLocalAppScenarioResponse.image_generate:type_name -> nimi.runtime.v1.LocalAppImageGenerateOutput - 11, // 127: nimi.runtime.v1.LocalAppVideoGenerateJobSpec.mode:type_name -> nimi.runtime.v1.VideoMode - 162, // 128: nimi.runtime.v1.LocalAppVideoGenerateJobSpec.content:type_name -> nimi.runtime.v1.VideoContentItem - 108, // 129: nimi.runtime.v1.LocalAppVideoGenerateJobSpec.options:type_name -> nimi.runtime.v1.LocalAppVideoGenerationOptions - 180, // 130: nimi.runtime.v1.LocalAppSpeechSynthesizeJobSpec.voice_ref:type_name -> nimi.runtime.v1.VoiceReference - 14, // 131: nimi.runtime.v1.LocalAppSpeechSynthesizeJobSpec.timing_mode:type_name -> nimi.runtime.v1.SpeechTimingMode - 164, // 132: nimi.runtime.v1.LocalAppSpeechSynthesizeJobSpec.voice_render_hints:type_name -> nimi.runtime.v1.VoiceRenderHints - 166, // 133: nimi.runtime.v1.LocalAppSpeechTranscribeJobSpec.audio_source:type_name -> nimi.runtime.v1.SpeechTranscriptionAudioSource - 181, // 134: nimi.runtime.v1.LocalAppVoiceCreateJobSpec.reference_audio:type_name -> nimi.runtime.v1.VoiceV2VInput - 182, // 135: nimi.runtime.v1.LocalAppVoiceCreateJobSpec.text_description:type_name -> nimi.runtime.v1.VoiceT2VInput - 103, // 136: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.image_generate:type_name -> nimi.runtime.v1.LocalAppImageGenerateScenarioSpec - 109, // 137: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.video_generate:type_name -> nimi.runtime.v1.LocalAppVideoGenerateJobSpec - 110, // 138: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.speech_synthesize:type_name -> nimi.runtime.v1.LocalAppSpeechSynthesizeJobSpec - 111, // 139: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.speech_transcribe:type_name -> nimi.runtime.v1.LocalAppSpeechTranscribeJobSpec - 112, // 140: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.voice_create:type_name -> nimi.runtime.v1.LocalAppVoiceCreateJobSpec - 113, // 141: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.music_generate:type_name -> nimi.runtime.v1.LocalAppMusicGenerateJobSpec - 114, // 142: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.world_generate:type_name -> nimi.runtime.v1.LocalAppWorldGenerateJobSpec - 77, // 143: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.vision_locate:type_name -> nimi.runtime.v1.VisionLocateScenarioSpec - 61, // 144: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.image_face_swap:type_name -> nimi.runtime.v1.ImageFaceSwapScenarioSpec - 75, // 145: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.video_face_swap:type_name -> nimi.runtime.v1.VideoFaceSwapScenarioSpec - 1, // 146: nimi.runtime.v1.LocalAppScenarioJob.scenario_type:type_name -> nimi.runtime.v1.ScenarioType - 22, // 147: nimi.runtime.v1.LocalAppScenarioJob.status:type_name -> nimi.runtime.v1.ScenarioJobStatus - 183, // 148: nimi.runtime.v1.LocalAppScenarioJob.reason_code:type_name -> nimi.runtime.v1.ReasonCode - 101, // 149: nimi.runtime.v1.LocalAppScenarioJob.artifacts:type_name -> nimi.runtime.v1.LocalAppScenarioArtifact - 186, // 150: nimi.runtime.v1.LocalAppScenarioJob.created_at:type_name -> google.protobuf.Timestamp - 186, // 151: nimi.runtime.v1.LocalAppScenarioJob.updated_at:type_name -> google.protobuf.Timestamp - 24, // 152: nimi.runtime.v1.LocalAppScenarioJob.interruption:type_name -> nimi.runtime.v1.ExecutionInterruption - 76, // 153: nimi.runtime.v1.LocalAppScenarioJob.video_face_swap_summary:type_name -> nimi.runtime.v1.VideoFaceSwapSummary - 187, // 154: nimi.runtime.v1.LocalAppVoiceAsset.status:type_name -> nimi.runtime.v1.VoiceAssetStatus - 186, // 155: nimi.runtime.v1.LocalAppVoiceAsset.created_at:type_name -> google.protobuf.Timestamp - 186, // 156: nimi.runtime.v1.LocalAppVoiceAsset.updated_at:type_name -> google.protobuf.Timestamp - 186, // 157: nimi.runtime.v1.LocalAppVoiceAsset.expires_at:type_name -> google.protobuf.Timestamp - 188, // 158: nimi.runtime.v1.LocalAppVoiceAsset.creation_source:type_name -> nimi.runtime.v1.VoiceCreationSource - 116, // 159: nimi.runtime.v1.SubmitLocalAppScenarioJobResponse.job:type_name -> nimi.runtime.v1.LocalAppScenarioJob - 116, // 160: nimi.runtime.v1.GetLocalAppScenarioJobResponse.job:type_name -> nimi.runtime.v1.LocalAppScenarioJob - 117, // 161: nimi.runtime.v1.GetLocalAppScenarioJobResponse.asset:type_name -> nimi.runtime.v1.LocalAppVoiceAsset - 180, // 162: nimi.runtime.v1.GetLocalAppScenarioJobResponse.voice_reference:type_name -> nimi.runtime.v1.VoiceReference - 81, // 163: nimi.runtime.v1.GetLocalAppScenarioJobResponse.vision_locate:type_name -> nimi.runtime.v1.VisionLocateResult - 116, // 164: nimi.runtime.v1.CancelLocalAppScenarioJobResponse.job:type_name -> nimi.runtime.v1.LocalAppScenarioJob - 23, // 165: nimi.runtime.v1.LocalAppScenarioJobEvent.event_type:type_name -> nimi.runtime.v1.ScenarioJobEventType - 186, // 166: nimi.runtime.v1.LocalAppScenarioJobEvent.timestamp:type_name -> google.protobuf.Timestamp - 116, // 167: nimi.runtime.v1.LocalAppScenarioJobEvent.job:type_name -> nimi.runtime.v1.LocalAppScenarioJob - 98, // 168: nimi.runtime.v1.StreamLocalAppTextTurnRequest.messages:type_name -> nimi.runtime.v1.LocalAppTextCandidateMessage - 4, // 169: nimi.runtime.v1.LocalAppTextTurnCompleted.finish_reason:type_name -> nimi.runtime.v1.FinishReason - 183, // 170: nimi.runtime.v1.LocalAppTextTurnFailed.reason_code:type_name -> nimi.runtime.v1.ReasonCode - 24, // 171: nimi.runtime.v1.LocalAppTextTurnFailed.interruption:type_name -> nimi.runtime.v1.ExecutionInterruption - 126, // 172: nimi.runtime.v1.StreamLocalAppTextTurnEvent.delta:type_name -> nimi.runtime.v1.LocalAppTextTurnDelta - 127, // 173: nimi.runtime.v1.StreamLocalAppTextTurnEvent.completed:type_name -> nimi.runtime.v1.LocalAppTextTurnCompleted - 128, // 174: nimi.runtime.v1.StreamLocalAppTextTurnEvent.failed:type_name -> nimi.runtime.v1.LocalAppTextTurnFailed - 117, // 175: nimi.runtime.v1.ListLocalAppVoiceAssetsResponse.assets:type_name -> nimi.runtime.v1.LocalAppVoiceAsset - 43, // 176: nimi.runtime.v1.StreamScenarioRequest.head:type_name -> nimi.runtime.v1.ScenarioRequestHead - 1, // 177: nimi.runtime.v1.StreamScenarioRequest.scenario_type:type_name -> nimi.runtime.v1.ScenarioType - 2, // 178: nimi.runtime.v1.StreamScenarioRequest.execution_mode:type_name -> nimi.runtime.v1.ExecutionMode - 82, // 179: nimi.runtime.v1.StreamScenarioRequest.spec:type_name -> nimi.runtime.v1.ScenarioSpec - 44, // 180: nimi.runtime.v1.StreamScenarioRequest.extensions:type_name -> nimi.runtime.v1.ScenarioExtension - 3, // 181: nimi.runtime.v1.ScenarioStreamStarted.route_decision:type_name -> nimi.runtime.v1.RoutePolicy - 189, // 182: nimi.runtime.v1.ScenarioStreamStarted.voice_output_mode:type_name -> nimi.runtime.v1.VoiceOutputMode - 185, // 183: nimi.runtime.v1.ScenarioStreamStarted.effective_input_identity:type_name -> nimi.runtime.v1.LoadoutEffectiveInputIdentity - 138, // 184: nimi.runtime.v1.ScenarioStreamDelta.artifact:type_name -> nimi.runtime.v1.ArtifactStreamDelta - 41, // 185: nimi.runtime.v1.ScenarioStreamDelta.source:type_name -> nimi.runtime.v1.TextSource - 42, // 186: nimi.runtime.v1.ScenarioStreamDelta.raw:type_name -> nimi.runtime.v1.RawChunk - 40, // 187: nimi.runtime.v1.ScenarioStreamDelta.text_output_item:type_name -> nimi.runtime.v1.TextOutputItemDelta - 4, // 188: nimi.runtime.v1.ScenarioStreamCompleted.finish_reason:type_name -> nimi.runtime.v1.FinishReason - 184, // 189: nimi.runtime.v1.ScenarioStreamCompleted.usage:type_name -> nimi.runtime.v1.UsageStats - 183, // 190: nimi.runtime.v1.ScenarioStreamFailed.reason_code:type_name -> nimi.runtime.v1.ReasonCode - 24, // 191: nimi.runtime.v1.ScenarioStreamFailed.interruption:type_name -> nimi.runtime.v1.ExecutionInterruption - 10, // 192: nimi.runtime.v1.StreamScenarioEvent.event_type:type_name -> nimi.runtime.v1.StreamEventType - 186, // 193: nimi.runtime.v1.StreamScenarioEvent.timestamp:type_name -> google.protobuf.Timestamp - 137, // 194: nimi.runtime.v1.StreamScenarioEvent.started:type_name -> nimi.runtime.v1.ScenarioStreamStarted - 139, // 195: nimi.runtime.v1.StreamScenarioEvent.delta:type_name -> nimi.runtime.v1.ScenarioStreamDelta - 184, // 196: nimi.runtime.v1.StreamScenarioEvent.usage:type_name -> nimi.runtime.v1.UsageStats - 140, // 197: nimi.runtime.v1.StreamScenarioEvent.completed:type_name -> nimi.runtime.v1.ScenarioStreamCompleted - 141, // 198: nimi.runtime.v1.StreamScenarioEvent.failed:type_name -> nimi.runtime.v1.ScenarioStreamFailed - 168, // 199: nimi.runtime.v1.ScenarioArtifact.speech_alignment:type_name -> nimi.runtime.v1.SpeechAlignment - 176, // 200: nimi.runtime.v1.ScenarioArtifact.metadata:type_name -> google.protobuf.Struct - 43, // 201: nimi.runtime.v1.ScenarioJob.head:type_name -> nimi.runtime.v1.ScenarioRequestHead - 1, // 202: nimi.runtime.v1.ScenarioJob.scenario_type:type_name -> nimi.runtime.v1.ScenarioType - 2, // 203: nimi.runtime.v1.ScenarioJob.execution_mode:type_name -> nimi.runtime.v1.ExecutionMode - 3, // 204: nimi.runtime.v1.ScenarioJob.route_decision:type_name -> nimi.runtime.v1.RoutePolicy - 22, // 205: nimi.runtime.v1.ScenarioJob.status:type_name -> nimi.runtime.v1.ScenarioJobStatus - 183, // 206: nimi.runtime.v1.ScenarioJob.reason_code:type_name -> nimi.runtime.v1.ReasonCode - 186, // 207: nimi.runtime.v1.ScenarioJob.created_at:type_name -> google.protobuf.Timestamp - 186, // 208: nimi.runtime.v1.ScenarioJob.updated_at:type_name -> google.protobuf.Timestamp - 186, // 209: nimi.runtime.v1.ScenarioJob.next_poll_at:type_name -> google.protobuf.Timestamp - 143, // 210: nimi.runtime.v1.ScenarioJob.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact - 184, // 211: nimi.runtime.v1.ScenarioJob.usage:type_name -> nimi.runtime.v1.UsageStats - 45, // 212: nimi.runtime.v1.ScenarioJob.ignored_extensions:type_name -> nimi.runtime.v1.IgnoredScenarioExtension - 176, // 213: nimi.runtime.v1.ScenarioJob.reason_metadata:type_name -> google.protobuf.Struct - 185, // 214: nimi.runtime.v1.ScenarioJob.effective_input_identity:type_name -> nimi.runtime.v1.LoadoutEffectiveInputIdentity - 24, // 215: nimi.runtime.v1.ScenarioJob.interruption:type_name -> nimi.runtime.v1.ExecutionInterruption - 76, // 216: nimi.runtime.v1.ScenarioJob.video_face_swap_summary:type_name -> nimi.runtime.v1.VideoFaceSwapSummary - 43, // 217: nimi.runtime.v1.SubmitScenarioJobRequest.head:type_name -> nimi.runtime.v1.ScenarioRequestHead - 1, // 218: nimi.runtime.v1.SubmitScenarioJobRequest.scenario_type:type_name -> nimi.runtime.v1.ScenarioType - 2, // 219: nimi.runtime.v1.SubmitScenarioJobRequest.execution_mode:type_name -> nimi.runtime.v1.ExecutionMode - 82, // 220: nimi.runtime.v1.SubmitScenarioJobRequest.spec:type_name -> nimi.runtime.v1.ScenarioSpec - 175, // 221: nimi.runtime.v1.SubmitScenarioJobRequest.labels:type_name -> nimi.runtime.v1.SubmitScenarioJobRequest.LabelsEntry - 44, // 222: nimi.runtime.v1.SubmitScenarioJobRequest.extensions:type_name -> nimi.runtime.v1.ScenarioExtension - 144, // 223: nimi.runtime.v1.SubmitScenarioJobResponse.job:type_name -> nimi.runtime.v1.ScenarioJob - 144, // 224: nimi.runtime.v1.GetScenarioJobResponse.job:type_name -> nimi.runtime.v1.ScenarioJob - 190, // 225: nimi.runtime.v1.GetScenarioJobResponse.asset:type_name -> nimi.runtime.v1.VoiceAsset - 180, // 226: nimi.runtime.v1.GetScenarioJobResponse.voice_reference:type_name -> nimi.runtime.v1.VoiceReference - 81, // 227: nimi.runtime.v1.GetScenarioJobResponse.vision_locate:type_name -> nimi.runtime.v1.VisionLocateResult - 144, // 228: nimi.runtime.v1.CancelScenarioJobResponse.job:type_name -> nimi.runtime.v1.ScenarioJob - 23, // 229: nimi.runtime.v1.ScenarioJobEvent.event_type:type_name -> nimi.runtime.v1.ScenarioJobEventType - 186, // 230: nimi.runtime.v1.ScenarioJobEvent.timestamp:type_name -> google.protobuf.Timestamp - 144, // 231: nimi.runtime.v1.ScenarioJobEvent.job:type_name -> nimi.runtime.v1.ScenarioJob - 143, // 232: nimi.runtime.v1.GetScenarioArtifactsResponse.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact - 96, // 233: nimi.runtime.v1.GetScenarioArtifactsResponse.output:type_name -> nimi.runtime.v1.ScenarioOutput - 1, // 234: nimi.runtime.v1.ScenarioProfile.scenario_type:type_name -> nimi.runtime.v1.ScenarioType - 2, // 235: nimi.runtime.v1.ScenarioProfile.supported_execution_modes:type_name -> nimi.runtime.v1.ExecutionMode - 155, // 236: nimi.runtime.v1.ListScenarioProfilesResponse.profiles:type_name -> nimi.runtime.v1.ScenarioProfile - 12, // 237: nimi.runtime.v1.VideoContentItem.type:type_name -> nimi.runtime.v1.VideoContentType - 13, // 238: nimi.runtime.v1.VideoContentItem.role:type_name -> nimi.runtime.v1.VideoContentRole - 158, // 239: nimi.runtime.v1.VideoContentItem.image_url:type_name -> nimi.runtime.v1.VideoContentImageURL - 159, // 240: nimi.runtime.v1.VideoContentItem.video_url:type_name -> nimi.runtime.v1.VideoContentVideoURL - 160, // 241: nimi.runtime.v1.VideoContentItem.audio_url:type_name -> nimi.runtime.v1.VideoContentAudioURL - 161, // 242: nimi.runtime.v1.VideoContentItem.artifact_ref:type_name -> nimi.runtime.v1.VideoContentArtifactRef - 165, // 243: nimi.runtime.v1.SpeechTranscriptionAudioSource.audio_chunks:type_name -> nimi.runtime.v1.AudioChunks - 15, // 244: nimi.runtime.v1.SpeechAlignment.unit:type_name -> nimi.runtime.v1.SpeechAlignmentUnit - 167, // 245: nimi.runtime.v1.SpeechAlignment.tokens:type_name -> nimi.runtime.v1.SpeechAlignmentToken - 184, // 246: nimi.runtime.v1.ArtifactChunk.usage:type_name -> nimi.runtime.v1.UsageStats - 3, // 247: nimi.runtime.v1.ArtifactChunk.route_decision:type_name -> nimi.runtime.v1.RoutePolicy - 170, // 248: nimi.runtime.v1.UploadArtifactRequest.metadata:type_name -> nimi.runtime.v1.UploadArtifactMetadata - 171, // 249: nimi.runtime.v1.UploadArtifactRequest.chunk:type_name -> nimi.runtime.v1.UploadArtifactChunk - 143, // 250: nimi.runtime.v1.UploadArtifactResponse.artifact:type_name -> nimi.runtime.v1.ScenarioArtifact - 63, // 251: nimi.runtime.v1.RuntimeAiVideoSessionService.OpenVideoSession:input_type -> nimi.runtime.v1.OpenVideoSessionRequest - 65, // 252: nimi.runtime.v1.RuntimeAiVideoSessionService.SubmitVideoSessionFrame:input_type -> nimi.runtime.v1.SubmitVideoSessionFrameRequest - 67, // 253: nimi.runtime.v1.RuntimeAiVideoSessionService.ReadVideoSessionResult:input_type -> nimi.runtime.v1.ReadVideoSessionResultRequest - 73, // 254: nimi.runtime.v1.RuntimeAiVideoSessionService.CloseVideoSession:input_type -> nimi.runtime.v1.CloseVideoSessionRequest - 191, // 255: nimi.runtime.v1.RuntimeAiService.GetAppAIConfig:input_type -> nimi.runtime.v1.GetAppAIConfigRequest - 192, // 256: nimi.runtime.v1.RuntimeAiService.OverwriteAppAIConfig:input_type -> nimi.runtime.v1.OverwriteAppAIConfigRequest - 193, // 257: nimi.runtime.v1.RuntimeAiService.ListAppAIConfigOptions:input_type -> nimi.runtime.v1.ListAppAIConfigOptionsRequest - 99, // 258: nimi.runtime.v1.RuntimeAiService.GenerateLocalAppTextCandidate:input_type -> nimi.runtime.v1.GenerateLocalAppTextCandidateRequest - 104, // 259: nimi.runtime.v1.RuntimeAiService.ExecuteLocalAppScenario:input_type -> nimi.runtime.v1.ExecuteLocalAppScenarioRequest - 115, // 260: nimi.runtime.v1.RuntimeAiService.SubmitLocalAppScenarioJob:input_type -> nimi.runtime.v1.SubmitLocalAppScenarioJobRequest - 119, // 261: nimi.runtime.v1.RuntimeAiService.GetLocalAppScenarioJob:input_type -> nimi.runtime.v1.GetLocalAppScenarioJobRequest - 121, // 262: nimi.runtime.v1.RuntimeAiService.CancelLocalAppScenarioJob:input_type -> nimi.runtime.v1.CancelLocalAppScenarioJobRequest - 123, // 263: nimi.runtime.v1.RuntimeAiService.SubscribeLocalAppScenarioJobEvents:input_type -> nimi.runtime.v1.SubscribeLocalAppScenarioJobEventsRequest - 125, // 264: nimi.runtime.v1.RuntimeAiService.StreamLocalAppTextTurn:input_type -> nimi.runtime.v1.StreamLocalAppTextTurnRequest - 130, // 265: nimi.runtime.v1.RuntimeAiService.ReadLocalAppArtifact:input_type -> nimi.runtime.v1.ReadLocalAppArtifactRequest - 132, // 266: nimi.runtime.v1.RuntimeAiService.UploadLocalAppArtifact:input_type -> nimi.runtime.v1.UploadLocalAppArtifactRequest - 134, // 267: nimi.runtime.v1.RuntimeAiService.ListLocalAppVoiceAssets:input_type -> nimi.runtime.v1.ListLocalAppVoiceAssetsRequest - 83, // 268: nimi.runtime.v1.RuntimeAiService.ExecuteScenario:input_type -> nimi.runtime.v1.ExecuteScenarioRequest - 136, // 269: nimi.runtime.v1.RuntimeAiService.StreamScenario:input_type -> nimi.runtime.v1.StreamScenarioRequest - 145, // 270: nimi.runtime.v1.RuntimeAiService.SubmitScenarioJob:input_type -> nimi.runtime.v1.SubmitScenarioJobRequest - 147, // 271: nimi.runtime.v1.RuntimeAiService.GetScenarioJob:input_type -> nimi.runtime.v1.GetScenarioJobRequest - 149, // 272: nimi.runtime.v1.RuntimeAiService.CancelScenarioJob:input_type -> nimi.runtime.v1.CancelScenarioJobRequest - 152, // 273: nimi.runtime.v1.RuntimeAiService.SubscribeScenarioJobEvents:input_type -> nimi.runtime.v1.SubscribeScenarioJobEventsRequest - 153, // 274: nimi.runtime.v1.RuntimeAiService.GetScenarioArtifacts:input_type -> nimi.runtime.v1.GetScenarioArtifactsRequest - 156, // 275: nimi.runtime.v1.RuntimeAiService.ListScenarioProfiles:input_type -> nimi.runtime.v1.ListScenarioProfilesRequest - 194, // 276: nimi.runtime.v1.RuntimeAiService.GetVoiceAsset:input_type -> nimi.runtime.v1.GetVoiceAssetRequest - 195, // 277: nimi.runtime.v1.RuntimeAiService.ListVoiceAssets:input_type -> nimi.runtime.v1.ListVoiceAssetsRequest - 196, // 278: nimi.runtime.v1.RuntimeAiService.DeleteVoiceAsset:input_type -> nimi.runtime.v1.DeleteVoiceAssetRequest - 197, // 279: nimi.runtime.v1.RuntimeAiService.ListPresetVoices:input_type -> nimi.runtime.v1.ListPresetVoicesRequest - 172, // 280: nimi.runtime.v1.RuntimeAiService.UploadArtifact:input_type -> nimi.runtime.v1.UploadArtifactRequest - 198, // 281: nimi.runtime.v1.RuntimeAiService.PeekScheduling:input_type -> nimi.runtime.v1.PeekSchedulingRequest - 64, // 282: nimi.runtime.v1.RuntimeAiVideoSessionService.OpenVideoSession:output_type -> nimi.runtime.v1.OpenVideoSessionResponse - 66, // 283: nimi.runtime.v1.RuntimeAiVideoSessionService.SubmitVideoSessionFrame:output_type -> nimi.runtime.v1.SubmitVideoSessionFrameResponse - 72, // 284: nimi.runtime.v1.RuntimeAiVideoSessionService.ReadVideoSessionResult:output_type -> nimi.runtime.v1.ReadVideoSessionResultResponse - 74, // 285: nimi.runtime.v1.RuntimeAiVideoSessionService.CloseVideoSession:output_type -> nimi.runtime.v1.CloseVideoSessionResponse - 199, // 286: nimi.runtime.v1.RuntimeAiService.GetAppAIConfig:output_type -> nimi.runtime.v1.GetAppAIConfigResponse - 200, // 287: nimi.runtime.v1.RuntimeAiService.OverwriteAppAIConfig:output_type -> nimi.runtime.v1.OverwriteAppAIConfigResponse - 201, // 288: nimi.runtime.v1.RuntimeAiService.ListAppAIConfigOptions:output_type -> nimi.runtime.v1.ListAppAIConfigOptionsResponse - 100, // 289: nimi.runtime.v1.RuntimeAiService.GenerateLocalAppTextCandidate:output_type -> nimi.runtime.v1.GenerateLocalAppTextCandidateResponse - 107, // 290: nimi.runtime.v1.RuntimeAiService.ExecuteLocalAppScenario:output_type -> nimi.runtime.v1.ExecuteLocalAppScenarioResponse - 118, // 291: nimi.runtime.v1.RuntimeAiService.SubmitLocalAppScenarioJob:output_type -> nimi.runtime.v1.SubmitLocalAppScenarioJobResponse - 120, // 292: nimi.runtime.v1.RuntimeAiService.GetLocalAppScenarioJob:output_type -> nimi.runtime.v1.GetLocalAppScenarioJobResponse - 122, // 293: nimi.runtime.v1.RuntimeAiService.CancelLocalAppScenarioJob:output_type -> nimi.runtime.v1.CancelLocalAppScenarioJobResponse - 124, // 294: nimi.runtime.v1.RuntimeAiService.SubscribeLocalAppScenarioJobEvents:output_type -> nimi.runtime.v1.LocalAppScenarioJobEvent - 129, // 295: nimi.runtime.v1.RuntimeAiService.StreamLocalAppTextTurn:output_type -> nimi.runtime.v1.StreamLocalAppTextTurnEvent - 131, // 296: nimi.runtime.v1.RuntimeAiService.ReadLocalAppArtifact:output_type -> nimi.runtime.v1.ReadLocalAppArtifactResponse - 133, // 297: nimi.runtime.v1.RuntimeAiService.UploadLocalAppArtifact:output_type -> nimi.runtime.v1.UploadLocalAppArtifactResponse - 135, // 298: nimi.runtime.v1.RuntimeAiService.ListLocalAppVoiceAssets:output_type -> nimi.runtime.v1.ListLocalAppVoiceAssetsResponse - 97, // 299: nimi.runtime.v1.RuntimeAiService.ExecuteScenario:output_type -> nimi.runtime.v1.ExecuteScenarioResponse - 142, // 300: nimi.runtime.v1.RuntimeAiService.StreamScenario:output_type -> nimi.runtime.v1.StreamScenarioEvent - 146, // 301: nimi.runtime.v1.RuntimeAiService.SubmitScenarioJob:output_type -> nimi.runtime.v1.SubmitScenarioJobResponse - 148, // 302: nimi.runtime.v1.RuntimeAiService.GetScenarioJob:output_type -> nimi.runtime.v1.GetScenarioJobResponse - 150, // 303: nimi.runtime.v1.RuntimeAiService.CancelScenarioJob:output_type -> nimi.runtime.v1.CancelScenarioJobResponse - 151, // 304: nimi.runtime.v1.RuntimeAiService.SubscribeScenarioJobEvents:output_type -> nimi.runtime.v1.ScenarioJobEvent - 154, // 305: nimi.runtime.v1.RuntimeAiService.GetScenarioArtifacts:output_type -> nimi.runtime.v1.GetScenarioArtifactsResponse - 157, // 306: nimi.runtime.v1.RuntimeAiService.ListScenarioProfiles:output_type -> nimi.runtime.v1.ListScenarioProfilesResponse - 202, // 307: nimi.runtime.v1.RuntimeAiService.GetVoiceAsset:output_type -> nimi.runtime.v1.GetVoiceAssetResponse - 203, // 308: nimi.runtime.v1.RuntimeAiService.ListVoiceAssets:output_type -> nimi.runtime.v1.ListVoiceAssetsResponse - 204, // 309: nimi.runtime.v1.RuntimeAiService.DeleteVoiceAsset:output_type -> nimi.runtime.v1.DeleteVoiceAssetResponse - 205, // 310: nimi.runtime.v1.RuntimeAiService.ListPresetVoices:output_type -> nimi.runtime.v1.ListPresetVoicesResponse - 173, // 311: nimi.runtime.v1.RuntimeAiService.UploadArtifact:output_type -> nimi.runtime.v1.UploadArtifactResponse - 206, // 312: nimi.runtime.v1.RuntimeAiService.PeekScheduling:output_type -> nimi.runtime.v1.PeekSchedulingResponse - 282, // [282:313] is the sub-list for method output_type - 251, // [251:282] is the sub-list for method input_type - 251, // [251:251] is the sub-list for extension type_name - 251, // [251:251] is the sub-list for extension extendee - 0, // [0:251] is the sub-list for field type_name + 188, // 118: nimi.runtime.v1.ExecuteScenarioResponse.effective_input_identity:type_name -> nimi.runtime.v1.LoadoutEffectiveInputIdentity + 37, // 119: nimi.runtime.v1.LocalAppTextCandidateMessage.turn_items:type_name -> nimi.runtime.v1.TextTurnItem + 98, // 120: nimi.runtime.v1.GenerateLocalAppTextCandidateRequest.messages:type_name -> nimi.runtime.v1.LocalAppTextCandidateMessage + 4, // 121: nimi.runtime.v1.GenerateLocalAppTextCandidateResponse.finish_reason:type_name -> nimi.runtime.v1.FinishReason + 102, // 122: nimi.runtime.v1.ExecuteLocalAppScenarioRequest.text_embed:type_name -> nimi.runtime.v1.LocalAppTextEmbedScenarioSpec + 103, // 123: nimi.runtime.v1.ExecuteLocalAppScenarioRequest.image_generate:type_name -> nimi.runtime.v1.LocalAppImageGenerateScenarioSpec + 126, // 124: nimi.runtime.v1.ExecuteLocalAppScenarioRequest.text_generate:type_name -> nimi.runtime.v1.StreamLocalAppTextTurnRequest + 85, // 125: nimi.runtime.v1.LocalAppTextEmbedOutput.vectors:type_name -> nimi.runtime.v1.EmbeddingVector + 101, // 126: nimi.runtime.v1.LocalAppImageGenerateOutput.artifacts:type_name -> nimi.runtime.v1.LocalAppScenarioArtifact + 36, // 127: nimi.runtime.v1.LocalAppTextGenerateOutput.items:type_name -> nimi.runtime.v1.TextOutputItem + 4, // 128: nimi.runtime.v1.LocalAppTextGenerateOutput.finish_reason:type_name -> nimi.runtime.v1.FinishReason + 105, // 129: nimi.runtime.v1.ExecuteLocalAppScenarioResponse.text_embed:type_name -> nimi.runtime.v1.LocalAppTextEmbedOutput + 106, // 130: nimi.runtime.v1.ExecuteLocalAppScenarioResponse.image_generate:type_name -> nimi.runtime.v1.LocalAppImageGenerateOutput + 107, // 131: nimi.runtime.v1.ExecuteLocalAppScenarioResponse.text_generate:type_name -> nimi.runtime.v1.LocalAppTextGenerateOutput + 11, // 132: nimi.runtime.v1.LocalAppVideoGenerateJobSpec.mode:type_name -> nimi.runtime.v1.VideoMode + 165, // 133: nimi.runtime.v1.LocalAppVideoGenerateJobSpec.content:type_name -> nimi.runtime.v1.VideoContentItem + 109, // 134: nimi.runtime.v1.LocalAppVideoGenerateJobSpec.options:type_name -> nimi.runtime.v1.LocalAppVideoGenerationOptions + 183, // 135: nimi.runtime.v1.LocalAppSpeechSynthesizeJobSpec.voice_ref:type_name -> nimi.runtime.v1.VoiceReference + 14, // 136: nimi.runtime.v1.LocalAppSpeechSynthesizeJobSpec.timing_mode:type_name -> nimi.runtime.v1.SpeechTimingMode + 167, // 137: nimi.runtime.v1.LocalAppSpeechSynthesizeJobSpec.voice_render_hints:type_name -> nimi.runtime.v1.VoiceRenderHints + 169, // 138: nimi.runtime.v1.LocalAppSpeechTranscribeJobSpec.audio_source:type_name -> nimi.runtime.v1.SpeechTranscriptionAudioSource + 184, // 139: nimi.runtime.v1.LocalAppVoiceCreateJobSpec.reference_audio:type_name -> nimi.runtime.v1.VoiceV2VInput + 185, // 140: nimi.runtime.v1.LocalAppVoiceCreateJobSpec.text_description:type_name -> nimi.runtime.v1.VoiceT2VInput + 103, // 141: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.image_generate:type_name -> nimi.runtime.v1.LocalAppImageGenerateScenarioSpec + 110, // 142: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.video_generate:type_name -> nimi.runtime.v1.LocalAppVideoGenerateJobSpec + 111, // 143: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.speech_synthesize:type_name -> nimi.runtime.v1.LocalAppSpeechSynthesizeJobSpec + 112, // 144: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.speech_transcribe:type_name -> nimi.runtime.v1.LocalAppSpeechTranscribeJobSpec + 113, // 145: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.voice_create:type_name -> nimi.runtime.v1.LocalAppVoiceCreateJobSpec + 114, // 146: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.music_generate:type_name -> nimi.runtime.v1.LocalAppMusicGenerateJobSpec + 115, // 147: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.world_generate:type_name -> nimi.runtime.v1.LocalAppWorldGenerateJobSpec + 77, // 148: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.vision_locate:type_name -> nimi.runtime.v1.VisionLocateScenarioSpec + 61, // 149: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.image_face_swap:type_name -> nimi.runtime.v1.ImageFaceSwapScenarioSpec + 75, // 150: nimi.runtime.v1.SubmitLocalAppScenarioJobRequest.video_face_swap:type_name -> nimi.runtime.v1.VideoFaceSwapScenarioSpec + 1, // 151: nimi.runtime.v1.LocalAppScenarioJob.scenario_type:type_name -> nimi.runtime.v1.ScenarioType + 22, // 152: nimi.runtime.v1.LocalAppScenarioJob.status:type_name -> nimi.runtime.v1.ScenarioJobStatus + 186, // 153: nimi.runtime.v1.LocalAppScenarioJob.reason_code:type_name -> nimi.runtime.v1.ReasonCode + 101, // 154: nimi.runtime.v1.LocalAppScenarioJob.artifacts:type_name -> nimi.runtime.v1.LocalAppScenarioArtifact + 189, // 155: nimi.runtime.v1.LocalAppScenarioJob.created_at:type_name -> google.protobuf.Timestamp + 189, // 156: nimi.runtime.v1.LocalAppScenarioJob.updated_at:type_name -> google.protobuf.Timestamp + 24, // 157: nimi.runtime.v1.LocalAppScenarioJob.interruption:type_name -> nimi.runtime.v1.ExecutionInterruption + 76, // 158: nimi.runtime.v1.LocalAppScenarioJob.video_face_swap_summary:type_name -> nimi.runtime.v1.VideoFaceSwapSummary + 190, // 159: nimi.runtime.v1.LocalAppVoiceAsset.status:type_name -> nimi.runtime.v1.VoiceAssetStatus + 189, // 160: nimi.runtime.v1.LocalAppVoiceAsset.created_at:type_name -> google.protobuf.Timestamp + 189, // 161: nimi.runtime.v1.LocalAppVoiceAsset.updated_at:type_name -> google.protobuf.Timestamp + 189, // 162: nimi.runtime.v1.LocalAppVoiceAsset.expires_at:type_name -> google.protobuf.Timestamp + 191, // 163: nimi.runtime.v1.LocalAppVoiceAsset.creation_source:type_name -> nimi.runtime.v1.VoiceCreationSource + 117, // 164: nimi.runtime.v1.SubmitLocalAppScenarioJobResponse.job:type_name -> nimi.runtime.v1.LocalAppScenarioJob + 117, // 165: nimi.runtime.v1.GetLocalAppScenarioJobResponse.job:type_name -> nimi.runtime.v1.LocalAppScenarioJob + 118, // 166: nimi.runtime.v1.GetLocalAppScenarioJobResponse.asset:type_name -> nimi.runtime.v1.LocalAppVoiceAsset + 183, // 167: nimi.runtime.v1.GetLocalAppScenarioJobResponse.voice_reference:type_name -> nimi.runtime.v1.VoiceReference + 81, // 168: nimi.runtime.v1.GetLocalAppScenarioJobResponse.vision_locate:type_name -> nimi.runtime.v1.VisionLocateResult + 117, // 169: nimi.runtime.v1.CancelLocalAppScenarioJobResponse.job:type_name -> nimi.runtime.v1.LocalAppScenarioJob + 23, // 170: nimi.runtime.v1.LocalAppScenarioJobEvent.event_type:type_name -> nimi.runtime.v1.ScenarioJobEventType + 189, // 171: nimi.runtime.v1.LocalAppScenarioJobEvent.timestamp:type_name -> google.protobuf.Timestamp + 117, // 172: nimi.runtime.v1.LocalAppScenarioJobEvent.job:type_name -> nimi.runtime.v1.LocalAppScenarioJob + 98, // 173: nimi.runtime.v1.StreamLocalAppTextTurnRequest.messages:type_name -> nimi.runtime.v1.LocalAppTextCandidateMessage + 29, // 174: nimi.runtime.v1.StreamLocalAppTextTurnRequest.tools:type_name -> nimi.runtime.v1.ToolSpec + 182, // 175: nimi.runtime.v1.StreamLocalAppTextTurnRequest.tool_choice:type_name -> nimi.runtime.v1.ToolChoiceMode + 30, // 176: nimi.runtime.v1.StreamLocalAppTextTurnRequest.response_format:type_name -> nimi.runtime.v1.ResponseFormat + 31, // 177: nimi.runtime.v1.LocalAppTextTurnToolCall.tool_call:type_name -> nimi.runtime.v1.ToolCall + 35, // 178: nimi.runtime.v1.LocalAppTextTurnContinuity.carrier:type_name -> nimi.runtime.v1.ReasoningContinuityCarrier + 4, // 179: nimi.runtime.v1.LocalAppTextTurnCompleted.finish_reason:type_name -> nimi.runtime.v1.FinishReason + 186, // 180: nimi.runtime.v1.LocalAppTextTurnFailed.reason_code:type_name -> nimi.runtime.v1.ReasonCode + 24, // 181: nimi.runtime.v1.LocalAppTextTurnFailed.interruption:type_name -> nimi.runtime.v1.ExecutionInterruption + 127, // 182: nimi.runtime.v1.StreamLocalAppTextTurnEvent.delta:type_name -> nimi.runtime.v1.LocalAppTextTurnDelta + 130, // 183: nimi.runtime.v1.StreamLocalAppTextTurnEvent.completed:type_name -> nimi.runtime.v1.LocalAppTextTurnCompleted + 131, // 184: nimi.runtime.v1.StreamLocalAppTextTurnEvent.failed:type_name -> nimi.runtime.v1.LocalAppTextTurnFailed + 128, // 185: nimi.runtime.v1.StreamLocalAppTextTurnEvent.tool_call:type_name -> nimi.runtime.v1.LocalAppTextTurnToolCall + 129, // 186: nimi.runtime.v1.StreamLocalAppTextTurnEvent.reasoning_continuity:type_name -> nimi.runtime.v1.LocalAppTextTurnContinuity + 118, // 187: nimi.runtime.v1.ListLocalAppVoiceAssetsResponse.assets:type_name -> nimi.runtime.v1.LocalAppVoiceAsset + 43, // 188: nimi.runtime.v1.StreamScenarioRequest.head:type_name -> nimi.runtime.v1.ScenarioRequestHead + 1, // 189: nimi.runtime.v1.StreamScenarioRequest.scenario_type:type_name -> nimi.runtime.v1.ScenarioType + 2, // 190: nimi.runtime.v1.StreamScenarioRequest.execution_mode:type_name -> nimi.runtime.v1.ExecutionMode + 82, // 191: nimi.runtime.v1.StreamScenarioRequest.spec:type_name -> nimi.runtime.v1.ScenarioSpec + 44, // 192: nimi.runtime.v1.StreamScenarioRequest.extensions:type_name -> nimi.runtime.v1.ScenarioExtension + 3, // 193: nimi.runtime.v1.ScenarioStreamStarted.route_decision:type_name -> nimi.runtime.v1.RoutePolicy + 192, // 194: nimi.runtime.v1.ScenarioStreamStarted.voice_output_mode:type_name -> nimi.runtime.v1.VoiceOutputMode + 188, // 195: nimi.runtime.v1.ScenarioStreamStarted.effective_input_identity:type_name -> nimi.runtime.v1.LoadoutEffectiveInputIdentity + 141, // 196: nimi.runtime.v1.ScenarioStreamDelta.artifact:type_name -> nimi.runtime.v1.ArtifactStreamDelta + 41, // 197: nimi.runtime.v1.ScenarioStreamDelta.source:type_name -> nimi.runtime.v1.TextSource + 42, // 198: nimi.runtime.v1.ScenarioStreamDelta.raw:type_name -> nimi.runtime.v1.RawChunk + 40, // 199: nimi.runtime.v1.ScenarioStreamDelta.text_output_item:type_name -> nimi.runtime.v1.TextOutputItemDelta + 4, // 200: nimi.runtime.v1.ScenarioStreamCompleted.finish_reason:type_name -> nimi.runtime.v1.FinishReason + 187, // 201: nimi.runtime.v1.ScenarioStreamCompleted.usage:type_name -> nimi.runtime.v1.UsageStats + 186, // 202: nimi.runtime.v1.ScenarioStreamFailed.reason_code:type_name -> nimi.runtime.v1.ReasonCode + 24, // 203: nimi.runtime.v1.ScenarioStreamFailed.interruption:type_name -> nimi.runtime.v1.ExecutionInterruption + 10, // 204: nimi.runtime.v1.StreamScenarioEvent.event_type:type_name -> nimi.runtime.v1.StreamEventType + 189, // 205: nimi.runtime.v1.StreamScenarioEvent.timestamp:type_name -> google.protobuf.Timestamp + 140, // 206: nimi.runtime.v1.StreamScenarioEvent.started:type_name -> nimi.runtime.v1.ScenarioStreamStarted + 142, // 207: nimi.runtime.v1.StreamScenarioEvent.delta:type_name -> nimi.runtime.v1.ScenarioStreamDelta + 187, // 208: nimi.runtime.v1.StreamScenarioEvent.usage:type_name -> nimi.runtime.v1.UsageStats + 143, // 209: nimi.runtime.v1.StreamScenarioEvent.completed:type_name -> nimi.runtime.v1.ScenarioStreamCompleted + 144, // 210: nimi.runtime.v1.StreamScenarioEvent.failed:type_name -> nimi.runtime.v1.ScenarioStreamFailed + 171, // 211: nimi.runtime.v1.ScenarioArtifact.speech_alignment:type_name -> nimi.runtime.v1.SpeechAlignment + 179, // 212: nimi.runtime.v1.ScenarioArtifact.metadata:type_name -> google.protobuf.Struct + 43, // 213: nimi.runtime.v1.ScenarioJob.head:type_name -> nimi.runtime.v1.ScenarioRequestHead + 1, // 214: nimi.runtime.v1.ScenarioJob.scenario_type:type_name -> nimi.runtime.v1.ScenarioType + 2, // 215: nimi.runtime.v1.ScenarioJob.execution_mode:type_name -> nimi.runtime.v1.ExecutionMode + 3, // 216: nimi.runtime.v1.ScenarioJob.route_decision:type_name -> nimi.runtime.v1.RoutePolicy + 22, // 217: nimi.runtime.v1.ScenarioJob.status:type_name -> nimi.runtime.v1.ScenarioJobStatus + 186, // 218: nimi.runtime.v1.ScenarioJob.reason_code:type_name -> nimi.runtime.v1.ReasonCode + 189, // 219: nimi.runtime.v1.ScenarioJob.created_at:type_name -> google.protobuf.Timestamp + 189, // 220: nimi.runtime.v1.ScenarioJob.updated_at:type_name -> google.protobuf.Timestamp + 189, // 221: nimi.runtime.v1.ScenarioJob.next_poll_at:type_name -> google.protobuf.Timestamp + 146, // 222: nimi.runtime.v1.ScenarioJob.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact + 187, // 223: nimi.runtime.v1.ScenarioJob.usage:type_name -> nimi.runtime.v1.UsageStats + 45, // 224: nimi.runtime.v1.ScenarioJob.ignored_extensions:type_name -> nimi.runtime.v1.IgnoredScenarioExtension + 179, // 225: nimi.runtime.v1.ScenarioJob.reason_metadata:type_name -> google.protobuf.Struct + 188, // 226: nimi.runtime.v1.ScenarioJob.effective_input_identity:type_name -> nimi.runtime.v1.LoadoutEffectiveInputIdentity + 24, // 227: nimi.runtime.v1.ScenarioJob.interruption:type_name -> nimi.runtime.v1.ExecutionInterruption + 76, // 228: nimi.runtime.v1.ScenarioJob.video_face_swap_summary:type_name -> nimi.runtime.v1.VideoFaceSwapSummary + 43, // 229: nimi.runtime.v1.SubmitScenarioJobRequest.head:type_name -> nimi.runtime.v1.ScenarioRequestHead + 1, // 230: nimi.runtime.v1.SubmitScenarioJobRequest.scenario_type:type_name -> nimi.runtime.v1.ScenarioType + 2, // 231: nimi.runtime.v1.SubmitScenarioJobRequest.execution_mode:type_name -> nimi.runtime.v1.ExecutionMode + 82, // 232: nimi.runtime.v1.SubmitScenarioJobRequest.spec:type_name -> nimi.runtime.v1.ScenarioSpec + 178, // 233: nimi.runtime.v1.SubmitScenarioJobRequest.labels:type_name -> nimi.runtime.v1.SubmitScenarioJobRequest.LabelsEntry + 44, // 234: nimi.runtime.v1.SubmitScenarioJobRequest.extensions:type_name -> nimi.runtime.v1.ScenarioExtension + 147, // 235: nimi.runtime.v1.SubmitScenarioJobResponse.job:type_name -> nimi.runtime.v1.ScenarioJob + 147, // 236: nimi.runtime.v1.GetScenarioJobResponse.job:type_name -> nimi.runtime.v1.ScenarioJob + 193, // 237: nimi.runtime.v1.GetScenarioJobResponse.asset:type_name -> nimi.runtime.v1.VoiceAsset + 183, // 238: nimi.runtime.v1.GetScenarioJobResponse.voice_reference:type_name -> nimi.runtime.v1.VoiceReference + 81, // 239: nimi.runtime.v1.GetScenarioJobResponse.vision_locate:type_name -> nimi.runtime.v1.VisionLocateResult + 147, // 240: nimi.runtime.v1.CancelScenarioJobResponse.job:type_name -> nimi.runtime.v1.ScenarioJob + 23, // 241: nimi.runtime.v1.ScenarioJobEvent.event_type:type_name -> nimi.runtime.v1.ScenarioJobEventType + 189, // 242: nimi.runtime.v1.ScenarioJobEvent.timestamp:type_name -> google.protobuf.Timestamp + 147, // 243: nimi.runtime.v1.ScenarioJobEvent.job:type_name -> nimi.runtime.v1.ScenarioJob + 146, // 244: nimi.runtime.v1.GetScenarioArtifactsResponse.artifacts:type_name -> nimi.runtime.v1.ScenarioArtifact + 96, // 245: nimi.runtime.v1.GetScenarioArtifactsResponse.output:type_name -> nimi.runtime.v1.ScenarioOutput + 1, // 246: nimi.runtime.v1.ScenarioProfile.scenario_type:type_name -> nimi.runtime.v1.ScenarioType + 2, // 247: nimi.runtime.v1.ScenarioProfile.supported_execution_modes:type_name -> nimi.runtime.v1.ExecutionMode + 158, // 248: nimi.runtime.v1.ListScenarioProfilesResponse.profiles:type_name -> nimi.runtime.v1.ScenarioProfile + 12, // 249: nimi.runtime.v1.VideoContentItem.type:type_name -> nimi.runtime.v1.VideoContentType + 13, // 250: nimi.runtime.v1.VideoContentItem.role:type_name -> nimi.runtime.v1.VideoContentRole + 161, // 251: nimi.runtime.v1.VideoContentItem.image_url:type_name -> nimi.runtime.v1.VideoContentImageURL + 162, // 252: nimi.runtime.v1.VideoContentItem.video_url:type_name -> nimi.runtime.v1.VideoContentVideoURL + 163, // 253: nimi.runtime.v1.VideoContentItem.audio_url:type_name -> nimi.runtime.v1.VideoContentAudioURL + 164, // 254: nimi.runtime.v1.VideoContentItem.artifact_ref:type_name -> nimi.runtime.v1.VideoContentArtifactRef + 168, // 255: nimi.runtime.v1.SpeechTranscriptionAudioSource.audio_chunks:type_name -> nimi.runtime.v1.AudioChunks + 15, // 256: nimi.runtime.v1.SpeechAlignment.unit:type_name -> nimi.runtime.v1.SpeechAlignmentUnit + 170, // 257: nimi.runtime.v1.SpeechAlignment.tokens:type_name -> nimi.runtime.v1.SpeechAlignmentToken + 187, // 258: nimi.runtime.v1.ArtifactChunk.usage:type_name -> nimi.runtime.v1.UsageStats + 3, // 259: nimi.runtime.v1.ArtifactChunk.route_decision:type_name -> nimi.runtime.v1.RoutePolicy + 173, // 260: nimi.runtime.v1.UploadArtifactRequest.metadata:type_name -> nimi.runtime.v1.UploadArtifactMetadata + 174, // 261: nimi.runtime.v1.UploadArtifactRequest.chunk:type_name -> nimi.runtime.v1.UploadArtifactChunk + 146, // 262: nimi.runtime.v1.UploadArtifactResponse.artifact:type_name -> nimi.runtime.v1.ScenarioArtifact + 63, // 263: nimi.runtime.v1.RuntimeAiVideoSessionService.OpenVideoSession:input_type -> nimi.runtime.v1.OpenVideoSessionRequest + 65, // 264: nimi.runtime.v1.RuntimeAiVideoSessionService.SubmitVideoSessionFrame:input_type -> nimi.runtime.v1.SubmitVideoSessionFrameRequest + 67, // 265: nimi.runtime.v1.RuntimeAiVideoSessionService.ReadVideoSessionResult:input_type -> nimi.runtime.v1.ReadVideoSessionResultRequest + 73, // 266: nimi.runtime.v1.RuntimeAiVideoSessionService.CloseVideoSession:input_type -> nimi.runtime.v1.CloseVideoSessionRequest + 194, // 267: nimi.runtime.v1.RuntimeAiService.GetAppAIConfig:input_type -> nimi.runtime.v1.GetAppAIConfigRequest + 195, // 268: nimi.runtime.v1.RuntimeAiService.OverwriteAppAIConfig:input_type -> nimi.runtime.v1.OverwriteAppAIConfigRequest + 196, // 269: nimi.runtime.v1.RuntimeAiService.ListAppAIConfigOptions:input_type -> nimi.runtime.v1.ListAppAIConfigOptionsRequest + 99, // 270: nimi.runtime.v1.RuntimeAiService.GenerateLocalAppTextCandidate:input_type -> nimi.runtime.v1.GenerateLocalAppTextCandidateRequest + 104, // 271: nimi.runtime.v1.RuntimeAiService.ExecuteLocalAppScenario:input_type -> nimi.runtime.v1.ExecuteLocalAppScenarioRequest + 116, // 272: nimi.runtime.v1.RuntimeAiService.SubmitLocalAppScenarioJob:input_type -> nimi.runtime.v1.SubmitLocalAppScenarioJobRequest + 120, // 273: nimi.runtime.v1.RuntimeAiService.GetLocalAppScenarioJob:input_type -> nimi.runtime.v1.GetLocalAppScenarioJobRequest + 122, // 274: nimi.runtime.v1.RuntimeAiService.CancelLocalAppScenarioJob:input_type -> nimi.runtime.v1.CancelLocalAppScenarioJobRequest + 124, // 275: nimi.runtime.v1.RuntimeAiService.SubscribeLocalAppScenarioJobEvents:input_type -> nimi.runtime.v1.SubscribeLocalAppScenarioJobEventsRequest + 126, // 276: nimi.runtime.v1.RuntimeAiService.StreamLocalAppTextTurn:input_type -> nimi.runtime.v1.StreamLocalAppTextTurnRequest + 133, // 277: nimi.runtime.v1.RuntimeAiService.ReadLocalAppArtifact:input_type -> nimi.runtime.v1.ReadLocalAppArtifactRequest + 135, // 278: nimi.runtime.v1.RuntimeAiService.UploadLocalAppArtifact:input_type -> nimi.runtime.v1.UploadLocalAppArtifactRequest + 137, // 279: nimi.runtime.v1.RuntimeAiService.ListLocalAppVoiceAssets:input_type -> nimi.runtime.v1.ListLocalAppVoiceAssetsRequest + 83, // 280: nimi.runtime.v1.RuntimeAiService.ExecuteScenario:input_type -> nimi.runtime.v1.ExecuteScenarioRequest + 139, // 281: nimi.runtime.v1.RuntimeAiService.StreamScenario:input_type -> nimi.runtime.v1.StreamScenarioRequest + 148, // 282: nimi.runtime.v1.RuntimeAiService.SubmitScenarioJob:input_type -> nimi.runtime.v1.SubmitScenarioJobRequest + 150, // 283: nimi.runtime.v1.RuntimeAiService.GetScenarioJob:input_type -> nimi.runtime.v1.GetScenarioJobRequest + 152, // 284: nimi.runtime.v1.RuntimeAiService.CancelScenarioJob:input_type -> nimi.runtime.v1.CancelScenarioJobRequest + 155, // 285: nimi.runtime.v1.RuntimeAiService.SubscribeScenarioJobEvents:input_type -> nimi.runtime.v1.SubscribeScenarioJobEventsRequest + 156, // 286: nimi.runtime.v1.RuntimeAiService.GetScenarioArtifacts:input_type -> nimi.runtime.v1.GetScenarioArtifactsRequest + 159, // 287: nimi.runtime.v1.RuntimeAiService.ListScenarioProfiles:input_type -> nimi.runtime.v1.ListScenarioProfilesRequest + 197, // 288: nimi.runtime.v1.RuntimeAiService.GetVoiceAsset:input_type -> nimi.runtime.v1.GetVoiceAssetRequest + 198, // 289: nimi.runtime.v1.RuntimeAiService.ListVoiceAssets:input_type -> nimi.runtime.v1.ListVoiceAssetsRequest + 199, // 290: nimi.runtime.v1.RuntimeAiService.DeleteVoiceAsset:input_type -> nimi.runtime.v1.DeleteVoiceAssetRequest + 200, // 291: nimi.runtime.v1.RuntimeAiService.ListPresetVoices:input_type -> nimi.runtime.v1.ListPresetVoicesRequest + 175, // 292: nimi.runtime.v1.RuntimeAiService.UploadArtifact:input_type -> nimi.runtime.v1.UploadArtifactRequest + 201, // 293: nimi.runtime.v1.RuntimeAiService.PeekScheduling:input_type -> nimi.runtime.v1.PeekSchedulingRequest + 64, // 294: nimi.runtime.v1.RuntimeAiVideoSessionService.OpenVideoSession:output_type -> nimi.runtime.v1.OpenVideoSessionResponse + 66, // 295: nimi.runtime.v1.RuntimeAiVideoSessionService.SubmitVideoSessionFrame:output_type -> nimi.runtime.v1.SubmitVideoSessionFrameResponse + 72, // 296: nimi.runtime.v1.RuntimeAiVideoSessionService.ReadVideoSessionResult:output_type -> nimi.runtime.v1.ReadVideoSessionResultResponse + 74, // 297: nimi.runtime.v1.RuntimeAiVideoSessionService.CloseVideoSession:output_type -> nimi.runtime.v1.CloseVideoSessionResponse + 202, // 298: nimi.runtime.v1.RuntimeAiService.GetAppAIConfig:output_type -> nimi.runtime.v1.GetAppAIConfigResponse + 203, // 299: nimi.runtime.v1.RuntimeAiService.OverwriteAppAIConfig:output_type -> nimi.runtime.v1.OverwriteAppAIConfigResponse + 204, // 300: nimi.runtime.v1.RuntimeAiService.ListAppAIConfigOptions:output_type -> nimi.runtime.v1.ListAppAIConfigOptionsResponse + 100, // 301: nimi.runtime.v1.RuntimeAiService.GenerateLocalAppTextCandidate:output_type -> nimi.runtime.v1.GenerateLocalAppTextCandidateResponse + 108, // 302: nimi.runtime.v1.RuntimeAiService.ExecuteLocalAppScenario:output_type -> nimi.runtime.v1.ExecuteLocalAppScenarioResponse + 119, // 303: nimi.runtime.v1.RuntimeAiService.SubmitLocalAppScenarioJob:output_type -> nimi.runtime.v1.SubmitLocalAppScenarioJobResponse + 121, // 304: nimi.runtime.v1.RuntimeAiService.GetLocalAppScenarioJob:output_type -> nimi.runtime.v1.GetLocalAppScenarioJobResponse + 123, // 305: nimi.runtime.v1.RuntimeAiService.CancelLocalAppScenarioJob:output_type -> nimi.runtime.v1.CancelLocalAppScenarioJobResponse + 125, // 306: nimi.runtime.v1.RuntimeAiService.SubscribeLocalAppScenarioJobEvents:output_type -> nimi.runtime.v1.LocalAppScenarioJobEvent + 132, // 307: nimi.runtime.v1.RuntimeAiService.StreamLocalAppTextTurn:output_type -> nimi.runtime.v1.StreamLocalAppTextTurnEvent + 134, // 308: nimi.runtime.v1.RuntimeAiService.ReadLocalAppArtifact:output_type -> nimi.runtime.v1.ReadLocalAppArtifactResponse + 136, // 309: nimi.runtime.v1.RuntimeAiService.UploadLocalAppArtifact:output_type -> nimi.runtime.v1.UploadLocalAppArtifactResponse + 138, // 310: nimi.runtime.v1.RuntimeAiService.ListLocalAppVoiceAssets:output_type -> nimi.runtime.v1.ListLocalAppVoiceAssetsResponse + 97, // 311: nimi.runtime.v1.RuntimeAiService.ExecuteScenario:output_type -> nimi.runtime.v1.ExecuteScenarioResponse + 145, // 312: nimi.runtime.v1.RuntimeAiService.StreamScenario:output_type -> nimi.runtime.v1.StreamScenarioEvent + 149, // 313: nimi.runtime.v1.RuntimeAiService.SubmitScenarioJob:output_type -> nimi.runtime.v1.SubmitScenarioJobResponse + 151, // 314: nimi.runtime.v1.RuntimeAiService.GetScenarioJob:output_type -> nimi.runtime.v1.GetScenarioJobResponse + 153, // 315: nimi.runtime.v1.RuntimeAiService.CancelScenarioJob:output_type -> nimi.runtime.v1.CancelScenarioJobResponse + 154, // 316: nimi.runtime.v1.RuntimeAiService.SubscribeScenarioJobEvents:output_type -> nimi.runtime.v1.ScenarioJobEvent + 157, // 317: nimi.runtime.v1.RuntimeAiService.GetScenarioArtifacts:output_type -> nimi.runtime.v1.GetScenarioArtifactsResponse + 160, // 318: nimi.runtime.v1.RuntimeAiService.ListScenarioProfiles:output_type -> nimi.runtime.v1.ListScenarioProfilesResponse + 205, // 319: nimi.runtime.v1.RuntimeAiService.GetVoiceAsset:output_type -> nimi.runtime.v1.GetVoiceAssetResponse + 206, // 320: nimi.runtime.v1.RuntimeAiService.ListVoiceAssets:output_type -> nimi.runtime.v1.ListVoiceAssetsResponse + 207, // 321: nimi.runtime.v1.RuntimeAiService.DeleteVoiceAsset:output_type -> nimi.runtime.v1.DeleteVoiceAssetResponse + 208, // 322: nimi.runtime.v1.RuntimeAiService.ListPresetVoices:output_type -> nimi.runtime.v1.ListPresetVoicesResponse + 176, // 323: nimi.runtime.v1.RuntimeAiService.UploadArtifact:output_type -> nimi.runtime.v1.UploadArtifactResponse + 209, // 324: nimi.runtime.v1.RuntimeAiService.PeekScheduling:output_type -> nimi.runtime.v1.PeekSchedulingResponse + 294, // [294:325] is the sub-list for method output_type + 263, // [263:294] is the sub-list for method input_type + 263, // [263:263] is the sub-list for extension type_name + 263, // [263:263] is the sub-list for extension extendee + 0, // [0:263] is the sub-list for field type_name } func init() { file_runtime_v1_ai_proto_init() } @@ -14494,19 +14827,21 @@ func file_runtime_v1_ai_proto_init() { file_runtime_v1_ai_proto_msgTypes[80].OneofWrappers = []any{ (*ExecuteLocalAppScenarioRequest_TextEmbed)(nil), (*ExecuteLocalAppScenarioRequest_ImageGenerate)(nil), + (*ExecuteLocalAppScenarioRequest_TextGenerate)(nil), } - file_runtime_v1_ai_proto_msgTypes[83].OneofWrappers = []any{ + file_runtime_v1_ai_proto_msgTypes[84].OneofWrappers = []any{ (*ExecuteLocalAppScenarioResponse_TextEmbed)(nil), (*ExecuteLocalAppScenarioResponse_ImageGenerate)(nil), + (*ExecuteLocalAppScenarioResponse_TextGenerate)(nil), } - file_runtime_v1_ai_proto_msgTypes[84].OneofWrappers = []any{} - file_runtime_v1_ai_proto_msgTypes[86].OneofWrappers = []any{} + file_runtime_v1_ai_proto_msgTypes[85].OneofWrappers = []any{} file_runtime_v1_ai_proto_msgTypes[87].OneofWrappers = []any{} - file_runtime_v1_ai_proto_msgTypes[88].OneofWrappers = []any{ + file_runtime_v1_ai_proto_msgTypes[88].OneofWrappers = []any{} + file_runtime_v1_ai_proto_msgTypes[89].OneofWrappers = []any{ (*LocalAppVoiceCreateJobSpec_ReferenceAudio)(nil), (*LocalAppVoiceCreateJobSpec_TextDescription)(nil), } - file_runtime_v1_ai_proto_msgTypes[91].OneofWrappers = []any{ + file_runtime_v1_ai_proto_msgTypes[92].OneofWrappers = []any{ (*SubmitLocalAppScenarioJobRequest_ImageGenerate)(nil), (*SubmitLocalAppScenarioJobRequest_VideoGenerate)(nil), (*SubmitLocalAppScenarioJobRequest_SpeechSynthesize)(nil), @@ -14518,33 +14853,35 @@ func file_runtime_v1_ai_proto_init() { (*SubmitLocalAppScenarioJobRequest_ImageFaceSwap)(nil), (*SubmitLocalAppScenarioJobRequest_VideoFaceSwap)(nil), } - file_runtime_v1_ai_proto_msgTypes[101].OneofWrappers = []any{} - file_runtime_v1_ai_proto_msgTypes[105].OneofWrappers = []any{ + file_runtime_v1_ai_proto_msgTypes[102].OneofWrappers = []any{} + file_runtime_v1_ai_proto_msgTypes[108].OneofWrappers = []any{ (*StreamLocalAppTextTurnEvent_Delta)(nil), (*StreamLocalAppTextTurnEvent_Completed)(nil), (*StreamLocalAppTextTurnEvent_Failed)(nil), + (*StreamLocalAppTextTurnEvent_ToolCall)(nil), + (*StreamLocalAppTextTurnEvent_ReasoningContinuity)(nil), } - file_runtime_v1_ai_proto_msgTypes[115].OneofWrappers = []any{ + file_runtime_v1_ai_proto_msgTypes[118].OneofWrappers = []any{ (*ScenarioStreamDelta_Artifact)(nil), (*ScenarioStreamDelta_Source)(nil), (*ScenarioStreamDelta_Raw)(nil), (*ScenarioStreamDelta_TextOutputItem)(nil), } - file_runtime_v1_ai_proto_msgTypes[118].OneofWrappers = []any{ + file_runtime_v1_ai_proto_msgTypes[121].OneofWrappers = []any{ (*StreamScenarioEvent_Started)(nil), (*StreamScenarioEvent_Delta)(nil), (*StreamScenarioEvent_Usage)(nil), (*StreamScenarioEvent_Completed)(nil), (*StreamScenarioEvent_Failed)(nil), } - file_runtime_v1_ai_proto_msgTypes[119].OneofWrappers = []any{} - file_runtime_v1_ai_proto_msgTypes[139].OneofWrappers = []any{} - file_runtime_v1_ai_proto_msgTypes[142].OneofWrappers = []any{ + file_runtime_v1_ai_proto_msgTypes[122].OneofWrappers = []any{} + file_runtime_v1_ai_proto_msgTypes[142].OneofWrappers = []any{} + file_runtime_v1_ai_proto_msgTypes[145].OneofWrappers = []any{ (*SpeechTranscriptionAudioSource_AudioBytes)(nil), (*SpeechTranscriptionAudioSource_AudioUri)(nil), (*SpeechTranscriptionAudioSource_AudioChunks)(nil), } - file_runtime_v1_ai_proto_msgTypes[148].OneofWrappers = []any{ + file_runtime_v1_ai_proto_msgTypes[151].OneofWrappers = []any{ (*UploadArtifactRequest_Metadata)(nil), (*UploadArtifactRequest_Chunk)(nil), } @@ -14554,7 +14891,7 @@ func file_runtime_v1_ai_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_runtime_v1_ai_proto_rawDesc), len(file_runtime_v1_ai_proto_rawDesc)), NumEnums: 24, - NumMessages: 152, + NumMessages: 155, NumExtensions: 0, NumServices: 2, }, diff --git a/runtime/internal/capabilitydriver/anthropic_text_behavior.go b/runtime/internal/capabilitydriver/anthropic_text_behavior.go new file mode 100644 index 0000000000..568d7d4c8d --- /dev/null +++ b/runtime/internal/capabilitydriver/anthropic_text_behavior.go @@ -0,0 +1,435 @@ +package capabilitydriver + +import ( + "encoding/json" + "fmt" + "strings" + + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "github.com/nimiplatform/nimi/runtime/internal/grpcerr" + "github.com/nimiplatform/nimi/runtime/internal/textbehavior" + "google.golang.org/grpc/codes" +) + +// @nimi-authority: rule.nimi.runtime.ai-provider.anthropic-sonnet46-text-behaviors +func AnthropicTextBehaviorRequestSerializer(spec *runtimev1.TextGenerateScenarioSpec, stream bool) (textbehavior.SerializedRequest, error) { + if spec == nil { + return textbehavior.SerializedRequest{}, anthropicBehaviorInput("missing text request") + } + if spec.GetIncludeRawChunks() { + return textbehavior.SerializedRequest{}, anthropicBehaviorUnsupported("raw provider chunks") + } + if spec.Seed != nil || spec.PresencePenalty != nil || spec.FrequencyPenalty != nil || spec.Temperature != nil && spec.TopP != nil { + return textbehavior.SerializedRequest{}, anthropicBehaviorUnsupported("sampling controls") + } + messages := make([]map[string]any, 0, len(spec.GetInput())) + system := []string{} + if spec.GetSystemPrompt() != "" { + system = append(system, spec.GetSystemPrompt()) + } + appendBlock := func(role string, block map[string]any) { + if len(messages) == 0 || messages[len(messages)-1]["role"] != role { + messages = append(messages, map[string]any{"role": role, "content": []map[string]any{}}) + } + last := messages[len(messages)-1] + last["content"] = append(last["content"].([]map[string]any), block) + } + for _, message := range spec.GetInput() { + if message == nil { + return textbehavior.SerializedRequest{}, anthropicBehaviorInput("nil message") + } + role := message.GetRole() + if len(message.GetTurnItems()) > 0 { + for _, item := range message.GetTurnItems() { + if result := item.GetToolResult(); result != nil { + value, err := json.Marshal(result.GetResult().AsInterface()) + if err != nil { + return textbehavior.SerializedRequest{}, anthropicBehaviorInput("tool result JSON") + } + appendBlock("user", map[string]any{"type": "tool_result", "tool_use_id": result.GetToolCallId(), "content": string(value), "is_error": result.GetIsError()}) + } else if text := item.GetOutput().GetText(); text != nil { + appendBlock("assistant", map[string]any{"type": "text", "text": text.GetText()}) + } else if call := item.GetOutput().GetToolCall(); call != nil { + var args map[string]json.RawMessage + if json.Unmarshal([]byte(call.GetArgumentsJson()), &args) != nil || args == nil { + return textbehavior.SerializedRequest{}, anthropicBehaviorInput("tool arguments JSON") + } + appendBlock("assistant", map[string]any{"type": "tool_use", "id": call.GetId(), "name": call.GetName(), "input": args}) + } else { + return textbehavior.SerializedRequest{}, anthropicBehaviorUnsupported("ordered content kind") + } + } + continue + } + if message.GetContent() != "" && len(message.GetParts()) > 0 { + return textbehavior.SerializedRequest{}, anthropicBehaviorInput("conflicting text representations") + } + text := message.GetContent() + if len(message.GetParts()) > 0 { + var content strings.Builder + for _, part := range message.GetParts() { + if part.GetType() != runtimev1.ChatContentPartType_CHAT_CONTENT_PART_TYPE_TEXT { + return textbehavior.SerializedRequest{}, anthropicBehaviorUnsupported("non-text content") + } + content.WriteString(part.GetText()) + } + text = content.String() + } + if role == "system" { + system = append(system, text) + continue + } + if role != "user" && role != "assistant" { + return textbehavior.SerializedRequest{}, anthropicBehaviorInput("message role") + } + appendBlock(role, map[string]any{"type": "text", "text": text}) + } + if len(messages) == 0 { + return textbehavior.SerializedRequest{}, anthropicBehaviorInput("empty messages") + } + maxTokens := spec.GetMaxTokens() + if maxTokens <= 0 { + maxTokens = 4096 + } + body := map[string]any{"messages": messages, "max_tokens": maxTokens, "stream": stream} + if len(system) > 0 { + body["system"] = strings.Join(system, "\n\n") + } + if spec.Temperature != nil { + body["temperature"] = spec.GetTemperature() + } + if spec.TopP != nil { + body["top_p"] = spec.GetTopP() + } + if spec.TopK != nil { + body["top_k"] = spec.GetTopK() + } + if len(spec.GetStop()) > 0 { + body["stop_sequences"] = spec.GetStop() + } + if len(spec.GetTools()) > 0 { + tools := make([]map[string]any, 0, len(spec.GetTools())) + for _, tool := range spec.GetTools() { + if tool.GetKind() != runtimev1.ToolSpecKind_TOOL_SPEC_KIND_FUNCTION { + return textbehavior.SerializedRequest{}, anthropicBehaviorUnsupported("provider tool") + } + schema := tool.GetInputSchema().AsMap() + if len(schema) == 0 { + schema = map[string]any{"type": "object"} + } + if _, err := textbehavior.CompileJSONSchema(schema); err != nil { + return textbehavior.SerializedRequest{}, anthropicBehaviorInput("tool schema") + } + tools = append(tools, map[string]any{"name": tool.GetName(), "description": tool.GetDescription(), "input_schema": schema}) + } + body["tools"] = tools + choice := map[string]any{"type": "auto"} + switch spec.GetToolChoice() { + case runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_NONE: + choice["type"] = "none" + case runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_REQUIRED: + choice["type"] = "any" + case runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_TOOL: + choice["type"], choice["name"] = "tool", spec.GetToolChoiceName() + } + body["tool_choice"] = choice + } + if format := spec.GetResponseFormat(); format != nil && format.GetKind() != runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_TEXT && format.GetKind() != runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_UNSPECIFIED { + if format.GetKind() != runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_SCHEMA { + return textbehavior.SerializedRequest{}, anthropicBehaviorUnsupported("response format") + } + schema := format.GetJsonSchema().AsMap() + if _, err := textbehavior.CompileJSONSchema(schema); err != nil { + return textbehavior.SerializedRequest{}, anthropicBehaviorInput("response schema") + } + if err := validateAnthropicStructuredSchema(schema); err != nil { + return textbehavior.SerializedRequest{}, err + } + body["output_config"] = map[string]any{"format": map[string]any{"type": "json_schema", "schema": schema}} + } + payload, err := json.Marshal(body) + if err != nil { + return textbehavior.SerializedRequest{}, anthropicBehaviorInput("request JSON") + } + return textbehavior.SerializedRequest{ContentType: "application/json", Payload: payload}, nil +} + +// This first slice accepts Vane's inline object/array/scalar schemas. Unsupported +// constraints are rejected, never erased to make the provider accept a request. +func validateAnthropicStructuredSchema(schema map[string]any) error { + for key, value := range schema { + switch key { + case "$schema", "title", "description", "type", "required", "default": + case "enum": + values, ok := value.([]any) + if !ok { + return anthropicBehaviorInput("schema enum") + } + for _, item := range values { + switch item.(type) { + case map[string]any, []any: + return anthropicBehaviorUnsupported("complex enum") + } + } + case "const": + switch value.(type) { + case map[string]any, []any: + return anthropicBehaviorUnsupported("complex const") + } + case "additionalProperties": + if value != false { + return anthropicBehaviorUnsupported("additionalProperties") + } + case "properties": + properties, ok := value.(map[string]any) + if !ok { + return anthropicBehaviorInput("schema properties") + } + for _, property := range properties { + child, ok := property.(map[string]any) + if !ok { + return anthropicBehaviorInput("schema property") + } + if err := validateAnthropicStructuredSchema(child); err != nil { + return err + } + } + case "items": + child, ok := value.(map[string]any) + if !ok { + return anthropicBehaviorUnsupported("tuple items") + } + if err := validateAnthropicStructuredSchema(child); err != nil { + return err + } + case "anyOf": + children, ok := value.([]any) + if !ok { + return anthropicBehaviorInput("anyOf") + } + for _, item := range children { + child, ok := item.(map[string]any) + if !ok { + return anthropicBehaviorInput("anyOf schema") + } + if err := validateAnthropicStructuredSchema(child); err != nil { + return err + } + } + default: + return anthropicBehaviorUnsupported("JSON Schema keyword " + key) + } + } + if schema["type"] == "object" && schema["additionalProperties"] != false { + return anthropicBehaviorUnsupported("open object schema") + } + return nil +} + +type anthropicBehaviorBlock struct { + Type string `json:"type"` + Text string `json:"text"` + ID string `json:"id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` +} +type anthropicBehaviorUsage struct { + Input int64 `json:"input_tokens"` + Output int64 `json:"output_tokens"` +} +type anthropicBehaviorMessage struct { + Content []anthropicBehaviorBlock `json:"content"` + Stop string `json:"stop_reason"` + Usage anthropicBehaviorUsage `json:"usage"` +} + +func AnthropicTextBehaviorNonStreamParser(payload []byte, spec *runtimev1.TextGenerateScenarioSpec) (textbehavior.NormalizedResult, error) { + var message anthropicBehaviorMessage + if json.Unmarshal(payload, &message) != nil { + return textbehavior.NormalizedResult{}, anthropicBehaviorOutput("response JSON") + } + assembler := textbehavior.NewOrderedStreamAssembler(spec.GetTools(), textbehavior.ValidateToolArguments) + for index, block := range message.Content { + fragment := textbehavior.PrivateFragment{ItemIndex: uint32(index), Complete: true} + switch block.Type { + case "text": + fragment.Kind, fragment.Text = textbehavior.OrderedItemText, block.Text + case "tool_use": + fragment.Kind, fragment.ToolCall = textbehavior.OrderedItemToolCall, &textbehavior.ToolCallFragment{IDPart: block.ID, NamePart: block.Name, ArgumentsJSONPart: string(block.Input)} + default: + return textbehavior.NormalizedResult{}, anthropicBehaviorOutput("content block kind") + } + if _, err := assembler.AppendFragment(fragment); err != nil { + return textbehavior.NormalizedResult{}, err + } + } + return finishAnthropicBehavior(assembler, message.Stop, message.Usage, spec) +} + +type anthropicBehaviorStream struct { + spec *runtimev1.TextGenerateScenarioSpec + assembler *textbehavior.OrderedStreamAssembler + blocks map[uint32]anthropicBehaviorBlock + arguments map[uint32]bool + started bool + stopped bool + stopReason string + usage anthropicBehaviorUsage +} + +func AnthropicTextBehaviorStreamAssembler(spec *runtimev1.TextGenerateScenarioSpec) (textbehavior.StreamFragmentAssembler, error) { + return &anthropicBehaviorStream{spec: spec, assembler: textbehavior.NewOrderedStreamAssembler(spec.GetTools(), textbehavior.ValidateToolArguments), blocks: map[uint32]anthropicBehaviorBlock{}, arguments: map[uint32]bool{}}, nil +} + +func (stream *anthropicBehaviorStream) Append(payload []byte) ([]textbehavior.OrderedDelta, error) { + var event struct { + Type string `json:"type"` + Index uint32 `json:"index"` + Message anthropicBehaviorMessage `json:"message"` + Block anthropicBehaviorBlock `json:"content_block"` + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + JSON string `json:"partial_json"` + Stop string `json:"stop_reason"` + } `json:"delta"` + Usage anthropicBehaviorUsage `json:"usage"` + } + if json.Unmarshal(payload, &event) != nil || stream.stopped { + return nil, anthropicBehaviorOutput("stream event") + } + if event.Type == "ping" { + return nil, nil + } + if event.Type == "error" { + return nil, grpcerr.WithReasonCode(codes.Unavailable, runtimev1.ReasonCode_AI_PROVIDER_UNAVAILABLE) + } + if event.Type == "message_start" { + if stream.started || len(event.Message.Content) != 0 { + return nil, anthropicBehaviorOutput("message start") + } + stream.started, stream.usage = true, event.Message.Usage + return nil, nil + } + if !stream.started { + return nil, anthropicBehaviorOutput("event before message start") + } + fragment := textbehavior.PrivateFragment{ItemIndex: event.Index} + switch event.Type { + case "content_block_start": + if _, exists := stream.blocks[event.Index]; exists { + return nil, anthropicBehaviorOutput("duplicate content block") + } + stream.blocks[event.Index] = event.Block + switch event.Block.Type { + case "text": + fragment.Kind, fragment.Text = textbehavior.OrderedItemText, event.Block.Text + case "tool_use": + fragment.Kind, fragment.ToolCall = textbehavior.OrderedItemToolCall, &textbehavior.ToolCallFragment{IDPart: event.Block.ID, NamePart: event.Block.Name} + default: + return nil, anthropicBehaviorOutput("stream content kind") + } + case "content_block_delta": + block, exists := stream.blocks[event.Index] + if !exists { + return nil, anthropicBehaviorOutput("delta without block") + } + if block.Type == "text" && event.Delta.Type == "text_delta" { + fragment.Kind, fragment.Text = textbehavior.OrderedItemText, event.Delta.Text + } else if block.Type == "tool_use" && event.Delta.Type == "input_json_delta" { + fragment.Kind, fragment.ToolCall = textbehavior.OrderedItemToolCall, &textbehavior.ToolCallFragment{ArgumentsJSONPart: event.Delta.JSON} + if event.Delta.JSON != "" { + stream.arguments[event.Index] = true + } + } else { + return nil, anthropicBehaviorOutput("stream delta kind") + } + case "content_block_stop": + block, exists := stream.blocks[event.Index] + if !exists { + return nil, anthropicBehaviorOutput("stop without block") + } + fragment.Complete = true + if block.Type == "text" { + fragment.Kind = textbehavior.OrderedItemText + } else { + fragment.Kind, fragment.ToolCall = textbehavior.OrderedItemToolCall, &textbehavior.ToolCallFragment{} + if !stream.arguments[event.Index] { + fragment.ToolCall.ArgumentsJSONPart = string(block.Input) + } + } + case "message_delta": + if event.Delta.Stop != "" { + stream.stopReason = event.Delta.Stop + } + stream.usage.Output = event.Usage.Output + return nil, nil + case "message_stop": + if stream.stopReason == "" { + return nil, anthropicBehaviorOutput("missing stop reason") + } + stream.stopped = true + return nil, nil + default: + return nil, anthropicBehaviorOutput("unknown stream event") + } + return stream.assembler.AppendFragment(fragment) +} + +func (stream *anthropicBehaviorStream) Finish() (textbehavior.NormalizedResult, error) { + if !stream.stopped { + return textbehavior.NormalizedResult{}, grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_TEXT_OUTPUT_INCOMPLETE) + } + return finishAnthropicBehavior(stream.assembler, stream.stopReason, stream.usage, stream.spec) +} + +func finishAnthropicBehavior(assembler *textbehavior.OrderedStreamAssembler, stop string, usage anthropicBehaviorUsage, spec *runtimev1.TextGenerateScenarioSpec) (textbehavior.NormalizedResult, error) { + var finish runtimev1.FinishReason + switch stop { + case "end_turn", "stop_sequence": + finish = runtimev1.FinishReason_FINISH_REASON_STOP + case "tool_use": + finish = runtimev1.FinishReason_FINISH_REASON_TOOL_CALL + case "max_tokens": + finish = runtimev1.FinishReason_FINISH_REASON_LENGTH + case "refusal": + finish = runtimev1.FinishReason_FINISH_REASON_CONTENT_FILTER + default: + return textbehavior.NormalizedResult{}, anthropicBehaviorOutput("stop reason") + } + items, err := assembler.FinishItems() + if err != nil { + return textbehavior.NormalizedResult{}, err + } + if spec.GetResponseFormat().GetKind() == runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_SCHEMA { + if finish != runtimev1.FinishReason_FINISH_REASON_STOP { + return textbehavior.NormalizedResult{}, grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_TEXT_OUTPUT_INCOMPLETE) + } + var text strings.Builder + for _, item := range items { + if item.Kind != textbehavior.OrderedItemText { + return textbehavior.NormalizedResult{}, anthropicBehaviorOutput("non-text structured output") + } + text.WriteString(item.Text) + } + var instance any + if json.Unmarshal([]byte(text.String()), &instance) != nil { + return textbehavior.NormalizedResult{}, anthropicBehaviorOutput("invalid structured JSON") + } + schema, err := textbehavior.CompileJSONSchema(spec.GetResponseFormat().GetJsonSchema().AsMap()) + if err != nil || schema.Validate(instance) != nil { + return textbehavior.NormalizedResult{}, anthropicBehaviorOutput("structured schema mismatch") + } + } + return textbehavior.NormalizedResult{Items: items, FinishReason: finish, Usage: &runtimev1.UsageStats{InputTokens: usage.Input, OutputTokens: usage.Output}}, nil +} + +func anthropicBehaviorInput(detail string) error { + return grpcerr.WrapWithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_AI_INPUT_INVALID, fmt.Errorf("Anthropic text input: %s", detail), grpcerr.ReasonOptions{}) +} +func anthropicBehaviorUnsupported(detail string) error { + return grpcerr.WrapWithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED, fmt.Errorf("Anthropic text behavior: %s", detail), grpcerr.ReasonOptions{}) +} +func anthropicBehaviorOutput(detail string) error { + return grpcerr.WrapWithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID, fmt.Errorf("Anthropic text output: %s", detail), grpcerr.ReasonOptions{}) +} diff --git a/runtime/internal/capabilitydriver/anthropic_text_behavior_test.go b/runtime/internal/capabilitydriver/anthropic_text_behavior_test.go new file mode 100644 index 0000000000..113f4f0b61 --- /dev/null +++ b/runtime/internal/capabilitydriver/anthropic_text_behavior_test.go @@ -0,0 +1,145 @@ +package capabilitydriver + +import ( + "encoding/json" + "testing" + + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "github.com/nimiplatform/nimi/runtime/internal/textbehavior" + "google.golang.org/protobuf/types/known/structpb" +) + +func anthropicBehaviorTestSpec(t *testing.T) *runtimev1.TextGenerateScenarioSpec { + t.Helper() + schema, err := structpb.NewStruct(map[string]any{"type": "object", "properties": map[string]any{"q": map[string]any{"type": "string"}}, "required": []any{"q"}, "additionalProperties": false}) + if err != nil { + t.Fatal(err) + } + return &runtimev1.TextGenerateScenarioSpec{ + Input: []*runtimev1.ChatMessage{{Role: "user", Content: "Find a document"}}, + Tools: []*runtimev1.ToolSpec{{Name: "lookup", Kind: runtimev1.ToolSpecKind_TOOL_SPEC_KIND_FUNCTION, InputSchema: schema}}, + ToolChoice: runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_REQUIRED, + } +} + +func TestAnthropicBehaviorMapsOrderedToolResultsAndChoices(t *testing.T) { + spec := anthropicBehaviorTestSpec(t) + spec.Input = append(spec.Input, &runtimev1.ChatMessage{Role: "assistant", TurnItems: []*runtimev1.TextTurnItem{ + {Item: &runtimev1.TextTurnItem_Output{Output: &runtimev1.TextOutputItem{Item: &runtimev1.TextOutputItem_Text{Text: &runtimev1.TextOutputText{Text: " Checking "}}}}}, + {Item: &runtimev1.TextTurnItem_Output{Output: &runtimev1.TextOutputItem{Item: &runtimev1.TextOutputItem_ToolCall{ToolCall: &runtimev1.ToolCall{Id: "call-1", Name: "lookup", ArgumentsJson: `{"q":"上海"}`}}}}}, + {Item: &runtimev1.TextTurnItem_ToolResult{ToolResult: &runtimev1.ToolResult{ToolCallId: "call-1", ToolName: "lookup", Result: structpb.NewStringValue("not available"), IsError: true}}}, + }}) + for mode, expected := range map[runtimev1.ToolChoiceMode]string{ + runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_AUTO: "auto", runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_NONE: "none", runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_REQUIRED: "any", runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_TOOL: "tool", + } { + spec.ToolChoice, spec.ToolChoiceName = mode, "lookup" + serialized, err := AnthropicTextBehaviorRequestSerializer(spec, true) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err := json.Unmarshal(serialized.Payload, &body); err != nil { + t.Fatal(err) + } + if _, hasModel := body["model"]; hasModel { + t.Fatal("adapter selected a model") + } + if body["tool_choice"].(map[string]any)["type"] != expected { + t.Fatalf("choice = %v", body["tool_choice"]) + } + messages := body["messages"].([]any) + if len(messages) != 3 || messages[1].(map[string]any)["role"] != "assistant" || messages[2].(map[string]any)["role"] != "user" { + t.Fatalf("roles = %v", messages) + } + blocks := messages[1].(map[string]any)["content"].([]any) + if blocks[0].(map[string]any)["text"] != " Checking " || blocks[1].(map[string]any)["id"] != "call-1" { + t.Fatalf("assistant order = %v", blocks) + } + result := messages[2].(map[string]any)["content"].([]any)[0].(map[string]any) + if result["tool_use_id"] != "call-1" || result["is_error"] != true { + t.Fatalf("result association = %v", result) + } + } +} + +func TestAnthropicBehaviorStreamKeepsArgumentsPrivateUntilBlockStop(t *testing.T) { + spec := anthropicBehaviorTestSpec(t) + stream, _ := AnthropicTextBehaviorStreamAssembler(spec) + frames := []string{ + `{"type":"message_start","message":{"content":[],"usage":{"input_tokens":10,"output_tokens":1}}}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Checking "}}`, + `{"type":"content_block_stop","index":0}`, + `{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call-1","name":"lookup","input":{}}}`, + `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"q\":\"上"}}`, + `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"海\"}"}}`, + } + for _, frame := range frames { + deltas, err := stream.Append([]byte(frame)) + if err != nil { + t.Fatal(err) + } + for _, delta := range deltas { + if delta.ToolCall != nil { + t.Fatal("partial call was published") + } + } + } + deltas, err := stream.Append([]byte(`{"type":"content_block_stop","index":1}`)) + if err != nil || len(deltas) != 1 || deltas[0].ToolCall.GetArgumentsJson() != `{"q":"上海"}` { + t.Fatalf("complete call = %+v %v", deltas, err) + } + if _, err := stream.Finish(); err == nil { + t.Fatal("missing message_stop accepted") + } + for _, frame := range []string{`{"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":12}}`, `{"type":"message_stop"}`} { + if _, err := stream.Append([]byte(frame)); err != nil { + t.Fatal(err) + } + } + result, err := stream.Finish() + if err != nil || len(result.Items) != 2 || result.Items[0].Text != "Checking " || result.Items[1].ToolCall.GetId() != "call-1" || result.Usage.GetOutputTokens() != 12 { + t.Fatalf("result = %+v %v", result, err) + } +} + +func TestAnthropicBehaviorSyncPreservesMixedOrderAndValidatesArguments(t *testing.T) { + spec := anthropicBehaviorTestSpec(t) + result, err := AnthropicTextBehaviorNonStreamParser([]byte(`{"content":[{"type":"tool_use","id":"call-1","name":"lookup","input":{"q":"one"}},{"type":"text","text":" then "},{"type":"tool_use","id":"call-2","name":"lookup","input":{"q":"two"}}],"stop_reason":"tool_use","usage":{"input_tokens":1,"output_tokens":2}}`), spec) + if err != nil || len(result.Items) != 3 || result.Items[1].Kind != textbehavior.OrderedItemText || result.Items[1].Text != " then " { + t.Fatalf("mixed order = %+v %v", result, err) + } + if _, err := AnthropicTextBehaviorNonStreamParser([]byte(`{"content":[{"type":"tool_use","id":"call-1","name":"lookup","input":{"q":42}}],"stop_reason":"tool_use"}`), spec); err == nil { + t.Fatal("schema-invalid arguments accepted") + } +} + +func TestAnthropicBehaviorUsesNativeStructuredOutputWithoutSchemaRepair(t *testing.T) { + spec := anthropicBehaviorTestSpec(t) + spec.ResponseFormat = &runtimev1.ResponseFormat{Kind: runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_SCHEMA, JsonSchema: spec.Tools[0].InputSchema, Strict: true} + spec.Tools = nil + serialized, err := AnthropicTextBehaviorRequestSerializer(spec, false) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err := json.Unmarshal(serialized.Payload, &body); err != nil { + t.Fatal(err) + } + format := body["output_config"].(map[string]any)["format"].(map[string]any) + if format["type"] != "json_schema" { + t.Fatalf("native format = %v", format) + } + if _, err := AnthropicTextBehaviorNonStreamParser([]byte(`{"content":[{"type":"text","text":"{\"q\":\"yes\"}"}],"stop_reason":"end_turn"}`), spec); err != nil { + t.Fatal(err) + } + for _, output := range []string{`{"content":[{"type":"text","text":"{\"q\":2}"}],"stop_reason":"end_turn"}`, `{"content":[{"type":"text","text":"{\"q\":\"yes\"}"}],"stop_reason":"max_tokens"}`} { + if _, err := AnthropicTextBehaviorNonStreamParser([]byte(output), spec); err == nil { + t.Fatal("invalid/truncated structured output accepted") + } + } + spec.ResponseFormat.JsonSchema.Fields["minProperties"] = structpb.NewNumberValue(1) + if _, err := AnthropicTextBehaviorRequestSerializer(spec, false); err == nil { + t.Fatal("unsupported constraint erased") + } +} diff --git a/runtime/internal/capabilitydriver/cloud_text.go b/runtime/internal/capabilitydriver/cloud_text.go index 6b0995db42..83fce2d413 100644 --- a/runtime/internal/capabilitydriver/cloud_text.go +++ b/runtime/internal/capabilitydriver/cloud_text.go @@ -12,6 +12,7 @@ import ( runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" "github.com/nimiplatform/nimi/runtime/internal/grpcerr" "github.com/nimiplatform/nimi/runtime/internal/providerregistry" + "github.com/nimiplatform/nimi/runtime/internal/textbehavior" "github.com/nimiplatform/nimi/runtime/internal/textwire" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -71,6 +72,33 @@ type CloudTextMappedRequest struct { spec *runtimev1.TextGenerateScenarioSpec stream bool wireDirectives textwire.Directives + behavior *textbehavior.Invocation + behaviorRequest textbehavior.SerializedRequest +} + +// WithTextBehavior fixes the already-selected hooks and serializes their request +// before Job publication. It performs no adapter or target selection. +func (r *CloudTextMappedRequest) WithTextBehavior(adapter *textbehavior.Adapter) (*CloudTextMappedRequest, error) { + if adapter == nil { + return r, nil + } + invocation, err := adapter.Bind(r.spec) + if err != nil { + return nil, err + } + serialized, err := invocation.Serialize(r.stream) + if err != nil { + return nil, err + } + cloned := *r + cloned.behavior, cloned.behaviorRequest = invocation, serialized + return &cloned, nil +} + +func (r *CloudTextMappedRequest) TextBehavior() (*textbehavior.Invocation, textbehavior.SerializedRequest) { + serialized := r.behaviorRequest + serialized.Payload = append([]byte(nil), serialized.Payload...) + return r.behavior, serialized } func (r *CloudTextMappedRequest) ProviderModelID() string { @@ -100,15 +128,14 @@ func (r *CloudTextMappedRequest) WireDirectives() textwire.Directives { // CloudTextTransportResponse is the credential-free normalized transport // carrier returned by Remote ExecutionHost to the Driver. type CloudTextTransportResponse struct { - Text string - ToolCalls []*runtimev1.ToolCall + Items []textbehavior.OrderedItem Usage *runtimev1.UsageStats FinishReason runtimev1.FinishReason - Streamed bool } // CloudTextResult is the final Runtime-normalized Driver response. type CloudTextResult struct { + Items []textbehavior.OrderedItem Text string ToolCalls []*runtimev1.ToolCall Usage *runtimev1.UsageStats @@ -327,8 +354,23 @@ func (providerCloudTextDriver) NormalizeResponse(response CloudTextTransportResp if finish == runtimev1.FinishReason_FINISH_REASON_ERROR { return CloudTextResult{}, cloudInvocationError(CloudInvocationFailureResponse, fmt.Errorf("provider returned an error finish reason without an error")) } - toolCalls := cloneCloudToolCalls(response.ToolCalls) - if !response.Streamed && response.Text == "" && len(toolCalls) == 0 { + var text strings.Builder + var toolCalls []*runtimev1.ToolCall + items := make([]textbehavior.OrderedItem, 0, len(response.Items)) + for _, item := range response.Items { + if item.Kind == textbehavior.OrderedItemText { + text.WriteString(item.Text) + } else if item.Kind == textbehavior.OrderedItemToolCall && item.ToolCall != nil { + item.ToolCall = proto.Clone(item.ToolCall).(*runtimev1.ToolCall) + toolCalls = append(toolCalls, item.ToolCall) + } else if item.Kind == textbehavior.OrderedItemReasoningContinuity && textbehavior.ValidContinuity(item.ReasoningContinuity) { + item.ReasoningContinuity = proto.Clone(item.ReasoningContinuity).(*runtimev1.ReasoningContinuityCarrier) + } else { + return CloudTextResult{}, cloudInvocationError(CloudInvocationFailureResponse, fmt.Errorf("unsupported ordered cloud text output")) + } + items = append(items, item) + } + if text.Len() == 0 && len(toolCalls) == 0 { return CloudTextResult{}, cloudInvocationError(CloudInvocationFailureResponse, fmt.Errorf("provider returned no text or tool call output")) } var usage *runtimev1.UsageStats @@ -336,7 +378,8 @@ func (providerCloudTextDriver) NormalizeResponse(response CloudTextTransportResp usage, _ = proto.Clone(response.Usage).(*runtimev1.UsageStats) } return CloudTextResult{ - Text: response.Text, + Text: text.String(), + Items: items, ToolCalls: toolCalls, Usage: usage, FinishReason: finish, @@ -353,6 +396,11 @@ func (providerCloudTextDriver) NormalizeReason(err error) error { if errors.Is(err, context.DeadlineExceeded) { return grpcerr.WrapWithReasonCode(codes.DeadlineExceeded, runtimev1.ReasonCode_AI_PROVIDER_TIMEOUT, err, grpcerr.ReasonOptions{Message: "provider request timed out"}) } + // A provider can reject a model for this account with HTTP 400. Preserve + // that classified cause instead of replacing it with a generic HTTP error. + if reason, ok := grpcerr.ExtractReasonCode(err); ok && reason == runtimev1.ReasonCode_AI_MODEL_NOT_FOUND { + return err + } if metadata, ok := grpcerr.ExtractReasonMetadata(err); ok { if statusCode, parseErr := strconv.Atoi(metadata["provider_http_status"]); parseErr == nil && statusCode > 0 { reason := CloudTextReasonForHTTPStatus(statusCode) @@ -379,6 +427,10 @@ func (providerCloudTextDriver) NormalizeReason(err error) error { runtimev1.ReasonCode_AI_CONTENT_FILTER_BLOCKED, runtimev1.ReasonCode_AI_INPUT_INVALID, runtimev1.ReasonCode_AI_OUTPUT_INVALID, + runtimev1.ReasonCode_AI_TOOL_CALL_INVALID, + runtimev1.ReasonCode_AI_REASONING_CONTINUITY_INVALID, + runtimev1.ReasonCode_AI_TEXT_OUTPUT_INCOMPLETE, + runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED, runtimev1.ReasonCode_AI_MODALITY_NOT_SUPPORTED: return err } @@ -562,23 +614,6 @@ func cloudDefaultInteger(value *structpb.Value) (int64, bool) { return int64(number), true } -func cloneCloudToolCalls(values []*runtimev1.ToolCall) []*runtimev1.ToolCall { - if len(values) == 0 { - return nil - } - out := make([]*runtimev1.ToolCall, 0, len(values)) - for _, value := range values { - if value == nil { - continue - } - cloned, _ := proto.Clone(value).(*runtimev1.ToolCall) - if cloned != nil { - out = append(out, cloned) - } - } - return out -} - func cloudInvocationError(kind CloudInvocationFailureKind, err error) error { return &CloudInvocationError{Kind: kind, Err: err} } diff --git a/runtime/internal/capabilitydriver/cloud_text_test.go b/runtime/internal/capabilitydriver/cloud_text_test.go index 6e96c09645..2dff210640 100644 --- a/runtime/internal/capabilitydriver/cloud_text_test.go +++ b/runtime/internal/capabilitydriver/cloud_text_test.go @@ -71,6 +71,10 @@ func TestCloudTextDriverNormalizesTransportHTTPMetadata(t *testing.T) { if reason, ok := grpcerr.ExtractReasonCode(normalized); !ok || reason != runtimev1.ReasonCode_AI_PROVIDER_INTERNAL { t.Fatalf("503 normalized reason = %v present=%v err=%v", reason, ok, normalized) } + modelError := grpcerr.WithReasonCodeOptions(codes.NotFound, runtimev1.ReasonCode_AI_MODEL_NOT_FOUND, grpcerr.ReasonOptions{Metadata: map[string]string{"provider_http_status": "400"}}) + if got := driver.NormalizeReason(modelError); got != modelError { + t.Fatalf("model rejection lost: %v", got) + } } func TestCloudTextDriverSeparatesTargetAndRequestMapping(t *testing.T) { diff --git a/runtime/internal/capabilitydriver/codex_text_behavior.go b/runtime/internal/capabilitydriver/codex_text_behavior.go new file mode 100644 index 0000000000..be3d87bbe1 --- /dev/null +++ b/runtime/internal/capabilitydriver/codex_text_behavior.go @@ -0,0 +1,494 @@ +package capabilitydriver + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "github.com/nimiplatform/nimi/runtime/internal/grpcerr" + "github.com/nimiplatform/nimi/runtime/internal/textbehavior" + "google.golang.org/grpc/codes" +) + +const codexContinuityKind = "openai_codex.responses.encrypted-reasoning" + +// @nimi-authority: rule.nimi.runtime.ai-provider.codex-text-behaviors +func CodexTextBehaviorRequestSerializer(spec *runtimev1.TextGenerateScenarioSpec, _ bool) (textbehavior.SerializedRequest, error) { + if spec == nil { + return textbehavior.SerializedRequest{}, codexInput("missing request") + } + if spec.GetIncludeRawChunks() { + return textbehavior.SerializedRequest{}, codexUnsupported("raw provider chunks") + } + // The subscription endpoint does not admit these generation controls. + if spec.Temperature != nil || spec.TopP != nil || spec.TopK != nil || spec.MaxTokens != nil || spec.Seed != nil || spec.PresencePenalty != nil || spec.FrequencyPenalty != nil || len(spec.Stop) > 0 { + return textbehavior.SerializedRequest{}, codexUnsupported("generation controls") + } + if reasoning := spec.Reasoning; reasoning != nil && (reasoning.Intensity != nil || + reasoning.Activation != runtimev1.ReasoningActivation_REASONING_ACTIVATION_UNSPECIFIED && reasoning.Activation != runtimev1.ReasoningActivation_REASONING_ACTIVATION_DISABLED || + reasoning.Presentation != runtimev1.ReasoningPresentation_REASONING_PRESENTATION_UNSPECIFIED && reasoning.Presentation != runtimev1.ReasoningPresentation_REASONING_PRESENTATION_HIDDEN) { + return textbehavior.SerializedRequest{}, codexUnsupported("reasoning controls") + } + input := make([]any, 0, len(spec.Input)) + instructions := []string{} + if spec.SystemPrompt != "" { + instructions = append(instructions, spec.SystemPrompt) + } + for _, message := range spec.Input { + if message == nil { + return textbehavior.SerializedRequest{}, codexInput("nil message") + } + if len(message.TurnItems) > 0 { + for _, item := range message.TurnItems { + switch { + case item.GetToolResult() != nil: + result := item.GetToolResult() + output := result.GetResult().AsInterface() + if result.IsError { + output = map[string]any{"isError": true, "result": output} + } + value, err := json.Marshal(output) + if err != nil { + return textbehavior.SerializedRequest{}, codexInput("tool result JSON") + } + input = append(input, map[string]any{"type": "function_call_output", "call_id": result.ToolCallId, "output": string(value)}) + case item.GetOutput().GetText() != nil: + input = append(input, map[string]any{"role": "assistant", "content": item.GetOutput().GetText().Text}) + case item.GetOutput().GetToolCall() != nil: + call := item.GetOutput().GetToolCall() + input = append(input, map[string]any{"type": "function_call", "call_id": call.Id, "name": call.Name, "arguments": call.ArgumentsJson}) + case item.GetOutput().GetReasoningContinuity() != nil: + carrier := item.GetOutput().GetReasoningContinuity() + if !textbehavior.ValidContinuity(carrier) || carrier.Kind != codexContinuityKind || carrier.Version != 1 { + return textbehavior.SerializedRequest{}, codexInput("continuity identity") + } + var reasoning codexEncryptedReasoning + decoder := json.NewDecoder(bytes.NewReader(carrier.Payload)) + decoder.DisallowUnknownFields() + if decoder.Decode(&reasoning) != nil || decoder.Decode(new(any)) != io.EOF || !reasoning.valid() { + return textbehavior.SerializedRequest{}, codexInput("continuity payload") + } + input = append(input, reasoning) + default: + return textbehavior.SerializedRequest{}, codexUnsupported("ordered content kind") + } + } + continue + } + if message.Content != "" && len(message.Parts) > 0 { + return textbehavior.SerializedRequest{}, codexInput("conflicting text representations") + } + var text strings.Builder + text.WriteString(message.Content) + for _, part := range message.Parts { + if part.GetType() != runtimev1.ChatContentPartType_CHAT_CONTENT_PART_TYPE_TEXT { + return textbehavior.SerializedRequest{}, codexUnsupported("non-text input") + } + text.WriteString(part.GetText()) + } + if message.Role == "system" { + instructions = append(instructions, text.String()) + continue + } + if message.Role != "user" && message.Role != "assistant" { + return textbehavior.SerializedRequest{}, codexInput("message role") + } + input = append(input, map[string]any{"role": message.Role, "content": text.String()}) + } + if len(input) == 0 { + return textbehavior.SerializedRequest{}, codexInput("empty input") + } + if len(instructions) == 0 { + instructions = append(instructions, "You are helpful, knowledgeable, and direct.") + } + body := map[string]any{"input": input, "instructions": strings.Join(instructions, "\n\n"), "store": false, "stream": true, "include": []string{"reasoning.encrypted_content"}} + if len(spec.Tools) > 0 { + tools := make([]any, 0, len(spec.Tools)) + for _, tool := range spec.Tools { + if tool.GetKind() != runtimev1.ToolSpecKind_TOOL_SPEC_KIND_FUNCTION { + return textbehavior.SerializedRequest{}, codexUnsupported("provider tool") + } + schema := tool.GetInputSchema().AsMap() + if _, err := textbehavior.CompileJSONSchema(schema); err != nil { + return textbehavior.SerializedRequest{}, codexInput("tool schema") + } + // Preserve the caller's schema, including optional fields. Runtime + // still validates every completed call before exposing it. + tools = append(tools, map[string]any{"type": "function", "name": tool.Name, "description": tool.Description, "parameters": schema, "strict": false}) + } + body["tools"], body["parallel_tool_calls"] = tools, true + choice := any("auto") + switch spec.ToolChoice { + case runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_NONE: + choice = "none" + case runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_REQUIRED: + choice = "required" + case runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_TOOL: + choice = map[string]any{"type": "function", "name": spec.ToolChoiceName} + } + body["tool_choice"] = choice + } + if format := spec.ResponseFormat; format != nil && format.Kind != runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_UNSPECIFIED && format.Kind != runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_TEXT { + if len(spec.Tools) > 0 || format.Kind != runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_SCHEMA { + return textbehavior.SerializedRequest{}, codexUnsupported("response format combination") + } + schema := format.GetJsonSchema().AsMap() + if _, err := textbehavior.CompileJSONSchema(schema); err != nil { + return textbehavior.SerializedRequest{}, codexInput("response schema") + } + if schema["type"] != "object" || schema["anyOf"] != nil { + return textbehavior.SerializedRequest{}, codexUnsupported("structured root") + } + if err := validateCodexStructuredSchema(schema); err != nil { + return textbehavior.SerializedRequest{}, err + } + name := format.SchemaName + if name == "" { + name = "response" + } + body["text"] = map[string]any{"format": map[string]any{"type": "json_schema", "name": name, "description": format.SchemaDescription, "schema": schema, "strict": true}} + } + payload, err := json.Marshal(body) + if err != nil { + return textbehavior.SerializedRequest{}, codexInput("request JSON") + } + return textbehavior.SerializedRequest{ContentType: "application/json", Payload: payload}, nil +} + +func validateCodexStructuredSchema(schema map[string]any) error { + for key, value := range schema { + switch key { + case "$schema", "title", "description", "type", "enum", "required", "pattern", "format", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf", "minItems", "maxItems", "$ref": + case "additionalProperties": + if value != false { + return codexUnsupported("open structured object") + } + case "properties", "$defs": + children, ok := value.(map[string]any) + if !ok { + return codexInput("schema object") + } + for _, child := range children { + node, ok := child.(map[string]any) + if !ok { + return codexInput("schema child") + } + if err := validateCodexStructuredSchema(node); err != nil { + return err + } + } + case "items": + child, ok := value.(map[string]any) + if !ok { + return codexUnsupported("tuple schema") + } + if err := validateCodexStructuredSchema(child); err != nil { + return err + } + case "anyOf": + children, ok := value.([]any) + if !ok { + return codexInput("schema alternatives") + } + for _, child := range children { + node, ok := child.(map[string]any) + if !ok { + return codexInput("schema alternative") + } + if err := validateCodexStructuredSchema(node); err != nil { + return err + } + } + default: + return codexUnsupported("JSON Schema keyword " + key) + } + } + if properties, ok := schema["properties"].(map[string]any); ok { + required, _ := schema["required"].([]any) + fields := map[string]bool{} + for _, name := range required { + if key, ok := name.(string); ok { + fields[key] = true + } + } + if schema["additionalProperties"] != false || len(fields) != len(properties) { + return codexUnsupported("optional structured property") + } + for name := range properties { + if !fields[name] { + return codexUnsupported("optional structured property") + } + } + } + return nil +} + +// Only encrypted continuity and the identity needed by Responses are retained. +// Raw reasoning and reasoning summaries never enter a public output item. +type codexEncryptedReasoning struct { + Type string `json:"type"` + ID string `json:"id"` + Encrypted string `json:"encrypted_content"` + Summary []any `json:"summary"` +} + +func (value codexEncryptedReasoning) valid() bool { + return value.Type == "reasoning" && value.ID != "" && value.Encrypted != "" && value.Summary != nil && len(value.Summary) == 0 +} + +type codexBehaviorItem struct { + Type string `json:"type"` + ID string `json:"id"` + CallID string `json:"call_id"` + Name string `json:"name"` + Arguments string `json:"arguments"` + Encrypted string `json:"encrypted_content"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` +} +type codexBehaviorResponse struct { + Status string `json:"status"` + Output []codexBehaviorItem `json:"output"` + Usage struct { + Input int64 `json:"input_tokens"` + Output int64 `json:"output_tokens"` + } `json:"usage"` +} +type codexBehaviorBlock struct { + item codexBehaviorItem + text, arguments strings.Builder + emitted int + done bool +} +type codexBehaviorStream struct { + spec *runtimev1.TextGenerateScenarioSpec + assembler *textbehavior.OrderedStreamAssembler + blocks []*codexBehaviorBlock + next uint32 + completed bool + response codexBehaviorResponse +} + +func CodexTextBehaviorStreamAssembler(spec *runtimev1.TextGenerateScenarioSpec) (textbehavior.StreamFragmentAssembler, error) { + return &codexBehaviorStream{spec: spec, assembler: textbehavior.NewOrderedStreamAssembler(spec.GetTools(), textbehavior.ValidateToolArguments)}, nil +} + +func (stream *codexBehaviorStream) Append(payload []byte) ([]textbehavior.OrderedDelta, error) { + if string(payload) == "[DONE]" && stream.completed { + return nil, nil + } + var event struct { + Type string `json:"type"` + Index uint32 `json:"output_index"` + ItemID string `json:"item_id"` + Delta string `json:"delta"` + Arguments string `json:"arguments"` + Item codexBehaviorItem `json:"item"` + Response codexBehaviorResponse `json:"response"` + } + if json.Unmarshal(payload, &event) != nil || stream.completed { + return nil, codexOutput("stream event") + } + switch event.Type { + case "response.created", "response.in_progress": + return nil, nil + case "error", "response.failed": + return nil, grpcerr.WithReasonCode(codes.Unavailable, runtimev1.ReasonCode_AI_PROVIDER_UNAVAILABLE) + case "response.incomplete": + return nil, grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_TEXT_OUTPUT_INCOMPLETE) + case "response.completed": + // The subscription endpoint omits already-streamed items from the + // terminal response. Completion seals the items collected above. + if event.Response.Status != "completed" || int(stream.next) != len(stream.blocks) { + return nil, codexOutput(fmt.Sprintf("response completion: status=%q complete=%d items=%d terminalItems=%d", event.Response.Status, stream.next, len(stream.blocks), len(event.Response.Output))) + } + stream.completed, stream.response = true, event.Response + return nil, nil + case "response.output_item.added": + if int(event.Index) != len(stream.blocks) || event.Item.ID == "" { + return nil, codexOutput("output item association") + } + if event.Item.Type != "message" && event.Item.Type != "function_call" && event.Item.Type != "reasoning" { + return nil, codexOutput("output item kind") + } + stream.blocks = append(stream.blocks, &codexBehaviorBlock{item: event.Item}) + return nil, nil + } + if int(event.Index) >= len(stream.blocks) { + return nil, codexOutput("event without output item") + } + block := stream.blocks[event.Index] + if block.done || event.ItemID != "" && event.ItemID != block.item.ID { + return nil, codexOutput("output item association") + } + switch event.Type { + case "response.output_text.delta": + if block.item.Type != "message" { + return nil, codexOutput("text delta kind") + } + block.text.WriteString(event.Delta) + case "response.function_call_arguments.delta": + if block.item.Type != "function_call" { + return nil, codexOutput("arguments delta kind") + } + block.arguments.WriteString(event.Delta) + case "response.function_call_arguments.done": + if block.item.Type != "function_call" || block.arguments.Len() > 0 && block.arguments.String() != event.Arguments { + return nil, codexOutput("arguments completion") + } + case "response.output_item.done": + if event.Item.ID != block.item.ID || event.Item.Type != block.item.Type { + return nil, codexOutput("item completion association") + } + if event.Item.Type == "function_call" && (event.Item.CallID != block.item.CallID || event.Item.Name != block.item.Name || block.arguments.Len() > 0 && block.arguments.String() != event.Item.Arguments) { + return nil, codexOutput("complete call association") + } + if event.Item.Type == "message" { + var text strings.Builder + for _, part := range event.Item.Content { + if part.Type != "output_text" { + return nil, codexOutput("message content") + } + text.WriteString(part.Text) + } + if block.text.Len() > 0 && block.text.String() != text.String() { + return nil, codexOutput("text completion") + } + if block.text.Len() == 0 { + block.text.WriteString(text.String()) + } + } + block.item, block.done = event.Item, true + case "response.content_part.added", "response.content_part.done", "response.output_text.done", + "response.reasoning_summary_part.added", "response.reasoning_summary_part.done", "response.reasoning_summary_text.delta", "response.reasoning_summary_text.done": + return nil, nil + default: + return nil, codexOutput("unsupported stream event " + event.Type) + } + if block.text.Len()+block.arguments.Len() > 256*1024 { + return nil, codexOutput("output size") + } + return stream.flush() +} + +// Responses can interleave argument fragments. Expose complete calls in output +// order; text at the current output index can still stream incrementally. +func (stream *codexBehaviorStream) flush() ([]textbehavior.OrderedDelta, error) { + var deltas []textbehavior.OrderedDelta + for int(stream.next) < len(stream.blocks) { + block := stream.blocks[stream.next] + fragment := textbehavior.PrivateFragment{ItemIndex: stream.next, Complete: block.done} + switch block.item.Type { + case "message": + fragment.Kind, fragment.Text = textbehavior.OrderedItemText, block.text.String()[block.emitted:] + if fragment.Text == "" && !block.done { + return deltas, nil + } + block.emitted = block.text.Len() + case "function_call": + if !block.done { + return deltas, nil + } + fragment.Kind, fragment.ToolCall = textbehavior.OrderedItemToolCall, &textbehavior.ToolCallFragment{IDPart: block.item.CallID, NamePart: block.item.Name, ArgumentsJSONPart: block.item.Arguments} + case "reasoning": + if !block.done { + return deltas, nil + } + reasoning := codexEncryptedReasoning{Type: "reasoning", ID: block.item.ID, Encrypted: block.item.Encrypted, Summary: []any{}} + if !reasoning.valid() { + return nil, codexOutput("missing encrypted continuity") + } + payload, err := json.Marshal(reasoning) + if err != nil { + return nil, codexOutput("continuity JSON") + } + fragment.Kind, fragment.ReasoningContinuity = textbehavior.OrderedItemReasoningContinuity, &runtimev1.ReasoningContinuityCarrier{Kind: codexContinuityKind, Version: 1, Payload: payload} + } + items, err := stream.assembler.AppendFragment(fragment) + if err != nil { + return nil, err + } + deltas = append(deltas, items...) + if !block.done { + return deltas, nil + } + stream.next++ + } + return deltas, nil +} + +func (stream *codexBehaviorStream) Finish() (textbehavior.NormalizedResult, error) { + if !stream.completed { + return textbehavior.NormalizedResult{}, grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_TEXT_OUTPUT_INCOMPLETE) + } + items, err := stream.assembler.FinishItems() + if err != nil { + return textbehavior.NormalizedResult{}, err + } + finish := runtimev1.FinishReason_FINISH_REASON_STOP + var text strings.Builder + for _, item := range items { + if item.Kind == textbehavior.OrderedItemToolCall { + finish = runtimev1.FinishReason_FINISH_REASON_TOOL_CALL + } + if item.Kind == textbehavior.OrderedItemText { + text.WriteString(item.Text) + } + } + if stream.spec.GetResponseFormat().GetKind() == runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_SCHEMA { + var instance any + decoder := json.NewDecoder(strings.NewReader(text.String())) + decoder.UseNumber() + if finish != runtimev1.FinishReason_FINISH_REASON_STOP || decoder.Decode(&instance) != nil || decoder.Decode(new(any)) != io.EOF { + return textbehavior.NormalizedResult{}, codexOutput("structured JSON") + } + schema, err := textbehavior.CompileJSONSchema(stream.spec.ResponseFormat.JsonSchema.AsMap()) + if err != nil || schema.Validate(instance) != nil { + return textbehavior.NormalizedResult{}, codexOutput("structured schema mismatch") + } + } + return textbehavior.NormalizedResult{Items: items, FinishReason: finish, Usage: &runtimev1.UsageStats{InputTokens: stream.response.Usage.Input, OutputTokens: stream.response.Usage.Output}}, nil +} + +// Normalizes a completed Responses object for the hook contract. The Codex +// subscription transport always uses the SSE assembler, including sync calls. +func CodexTextBehaviorNonStreamParser(payload []byte, spec *runtimev1.TextGenerateScenarioSpec) (textbehavior.NormalizedResult, error) { + var response codexBehaviorResponse + if json.Unmarshal(payload, &response) != nil || response.Status != "completed" { + return textbehavior.NormalizedResult{}, codexOutput("response JSON") + } + stream := &codexBehaviorStream{spec: spec, assembler: textbehavior.NewOrderedStreamAssembler(spec.GetTools(), textbehavior.ValidateToolArguments), response: response} + for _, item := range response.Output { + block := &codexBehaviorBlock{item: item, done: true} + if item.Type != "message" && item.Type != "function_call" && item.Type != "reasoning" { + return textbehavior.NormalizedResult{}, codexOutput("output kind") + } + for _, part := range item.Content { + if part.Type != "output_text" { + return textbehavior.NormalizedResult{}, codexOutput("message content") + } + block.text.WriteString(part.Text) + } + stream.blocks = append(stream.blocks, block) + } + if _, err := stream.flush(); err != nil { + return textbehavior.NormalizedResult{}, err + } + stream.completed = true + return stream.Finish() +} + +func codexInput(detail string) error { + return grpcerr.WrapWithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_AI_INPUT_INVALID, fmt.Errorf("Codex text input: %s", detail), grpcerr.ReasonOptions{}) +} +func codexUnsupported(detail string) error { + return grpcerr.WrapWithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED, fmt.Errorf("Codex text behavior: %s", detail), grpcerr.ReasonOptions{}) +} +func codexOutput(detail string) error { + return grpcerr.WrapWithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID, fmt.Errorf("Codex text output: %s", detail), grpcerr.ReasonOptions{}) +} diff --git a/runtime/internal/capabilitydriver/codex_text_behavior_test.go b/runtime/internal/capabilitydriver/codex_text_behavior_test.go new file mode 100644 index 0000000000..408a061796 --- /dev/null +++ b/runtime/internal/capabilitydriver/codex_text_behavior_test.go @@ -0,0 +1,130 @@ +package capabilitydriver + +import ( + "encoding/json" + "testing" + + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "github.com/nimiplatform/nimi/runtime/internal/textbehavior" + "google.golang.org/protobuf/types/known/structpb" +) + +func codexBehaviorSpec(t *testing.T) *runtimev1.TextGenerateScenarioSpec { + t.Helper() + schema, err := structpb.NewStruct(map[string]any{"type": "object", "properties": map[string]any{"q": map[string]any{"type": "string"}}, "required": []any{"q"}, "additionalProperties": false}) + if err != nil { + t.Fatal(err) + } + return &runtimev1.TextGenerateScenarioSpec{Input: []*runtimev1.ChatMessage{{Role: "user", Content: "look up both"}}, Tools: []*runtimev1.ToolSpec{{Kind: runtimev1.ToolSpecKind_TOOL_SPEC_KIND_FUNCTION, Name: "lookup", InputSchema: schema}}, ToolChoice: runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_REQUIRED} +} + +func TestCodexBehaviorPreservesOpaqueContinuityAndResultAssociation(t *testing.T) { + spec := codexBehaviorSpec(t) + result, err := CodexTextBehaviorNonStreamParser([]byte(`{"status":"completed","output":[{"type":"reasoning","id":"rs_1","encrypted_content":"opaque-test-value","summary":[]},{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"one\"}"},{"type":"message","id":"msg_1","content":[{"type":"output_text","text":" then "}]},{"type":"function_call","id":"fc_2","call_id":"call_2","name":"lookup","arguments":"{\"q\":\"two\"}"}]}`), spec) + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 4 || result.Items[0].Kind != textbehavior.OrderedItemReasoningContinuity || result.Items[1].ToolCall.Id != "call_1" || result.Items[2].Text != " then " { + t.Fatalf("ordered result = %+v", result) + } + turn := &runtimev1.ChatMessage{Role: "assistant"} + turn.TurnItems = append(turn.TurnItems, &runtimev1.TextTurnItem{Item: &runtimev1.TextTurnItem_Output{Output: &runtimev1.TextOutputItem{Item: &runtimev1.TextOutputItem_ReasoningContinuity{ReasoningContinuity: result.Items[0].ReasoningContinuity}}}}) + for _, index := range []int{1, 3} { + turn.TurnItems = append(turn.TurnItems, &runtimev1.TextTurnItem{Item: &runtimev1.TextTurnItem_Output{Output: &runtimev1.TextOutputItem{Item: &runtimev1.TextOutputItem_ToolCall{ToolCall: result.Items[index].ToolCall}}}}) + } + for _, index := range []int{3, 1} { + call := result.Items[index].ToolCall + turn.TurnItems = append(turn.TurnItems, &runtimev1.TextTurnItem{Item: &runtimev1.TextTurnItem_ToolResult{ToolResult: &runtimev1.ToolResult{ToolCallId: call.Id, ToolName: call.Name, Result: structpb.NewStringValue("failed"), IsError: true}}}) + } + spec.Input = append(spec.Input, turn) + for _, publicStream := range []bool{false, true} { + request, err := CodexTextBehaviorRequestSerializer(spec, publicStream) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err := json.Unmarshal(request.Payload, &body); err != nil { + t.Fatal(err) + } + input := body["input"].([]any) + if body["store"] != false || body["stream"] != true || body["tool_choice"] != "required" || input[1].(map[string]any)["encrypted_content"] != "opaque-test-value" || input[4].(map[string]any)["call_id"] != "call_2" || input[5].(map[string]any)["call_id"] != "call_1" { + t.Fatalf("request projection = %v", body) + } + var failure map[string]any + _ = json.Unmarshal([]byte(input[4].(map[string]any)["output"].(string)), &failure) + if failure["isError"] != true { + t.Fatal("tool failure status lost") + } + } +} + +func TestCodexBehaviorStreamCollectsInterleavedCompleteCallsAndRequiresTerminal(t *testing.T) { + stream, _ := CodexTextBehaviorStreamAssembler(codexBehaviorSpec(t)) + frames := []string{ + `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup","arguments":""}}`, + `{"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"fc_2","call_id":"call_2","name":"lookup","arguments":""}}`, + `{"type":"response.function_call_arguments.delta","output_index":0,"item_id":"fc_1","delta":"{\"q\":\"上"}`, + `{"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"fc_2","call_id":"call_2","name":"lookup","arguments":"{\"q\":\"two\"}"}}`, + `{"type":"response.function_call_arguments.delta","output_index":0,"item_id":"fc_1","delta":"海\"}"}`, + } + for _, frame := range frames { + deltas, err := stream.Append([]byte(frame)) + if err != nil { + t.Fatal(err) + } + if len(deltas) > 0 { + t.Fatal("unfinished or out-of-order call exposed") + } + } + deltas, err := stream.Append([]byte(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"上海\"}"}}`)) + if err != nil || len(deltas) != 2 || deltas[0].ToolCall.GetId() != "call_1" || deltas[1].ToolCall.GetId() != "call_2" { + t.Fatalf("complete ordered calls = %+v %v", deltas, err) + } + if _, err := stream.Finish(); err == nil { + t.Fatal("missing terminal accepted") + } + if _, err := stream.Append([]byte(`{"type":"response.completed","response":{"status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":7}}}`)); err != nil { + t.Fatal(err) + } + result, err := stream.Finish() + if err != nil || result.Usage.GetOutputTokens() != 7 || result.FinishReason != runtimev1.FinishReason_FINISH_REASON_TOOL_CALL { + t.Fatalf("result = %+v %v", result, err) + } +} + +func TestCodexBehaviorStructuredOutputIsNativeAndValidatesFinalValue(t *testing.T) { + spec := codexBehaviorSpec(t) + spec.ResponseFormat = &runtimev1.ResponseFormat{Kind: runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_SCHEMA, JsonSchema: spec.Tools[0].InputSchema, Strict: true} + spec.Tools, spec.ToolChoice = nil, runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_UNSPECIFIED + request, err := CodexTextBehaviorRequestSerializer(spec, false) + if err != nil { + t.Fatal(err) + } + var body map[string]any + _ = json.Unmarshal(request.Payload, &body) + if body["text"].(map[string]any)["format"].(map[string]any)["type"] != "json_schema" { + t.Fatal("not native structured output") + } + for _, value := range []string{`{"q":"valid"}`, `{"q":2}`} { + response, _ := json.Marshal(map[string]any{"status": "completed", "output": []any{map[string]any{"type": "message", "id": "msg_1", "content": []any{map[string]any{"type": "output_text", "text": value}}}}}) + _, err := CodexTextBehaviorNonStreamParser(response, spec) + if (err == nil) != (value == `{"q":"valid"}`) { + t.Fatalf("schema result %s: %v", value, err) + } + } + spec.ResponseFormat.JsonSchema.Fields["minLength"] = structpb.NewNumberValue(1) + if _, err := CodexTextBehaviorRequestSerializer(spec, true); err == nil { + t.Fatal("unsupported schema constraint erased") + } +} + +func TestCodexBehaviorRejectsInvalidArgumentsAndCarrierOnlyOutput(t *testing.T) { + for _, response := range []string{ + `{"status":"completed","output":[{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup","arguments":"{\"q\":2}"}]}`, + `{"status":"completed","output":[{"type":"reasoning","id":"rs_1","encrypted_content":"test"}]}`, + } { + if _, err := CodexTextBehaviorNonStreamParser([]byte(response), codexBehaviorSpec(t)); err == nil { + t.Fatal("invalid output accepted") + } + } +} diff --git a/runtime/internal/capabilitydriver/llama_text_behavior_gemma4.go b/runtime/internal/capabilitydriver/llama_text_behavior_gemma4.go index 63746b9357..0c82d93e5f 100644 --- a/runtime/internal/capabilitydriver/llama_text_behavior_gemma4.go +++ b/runtime/internal/capabilitydriver/llama_text_behavior_gemma4.go @@ -10,18 +10,9 @@ import ( runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" "github.com/nimiplatform/nimi/runtime/internal/grpcerr" "github.com/nimiplatform/nimi/runtime/internal/textbehavior" - jsonschema "github.com/santhosh-tekuri/jsonschema/v6" "google.golang.org/grpc/codes" ) -const gemma4SchemaResource = "urn:nimi:text-behavior:gemma4:schema" - -type gemma4NoNetworkSchemaLoader struct{} - -func (gemma4NoNetworkSchemaLoader) Load(url string) (any, error) { - return nil, fmt.Errorf("remote JSON Schema resource %q is unavailable", url) -} - func Gemma4TextBehaviorRequestSerializer(spec *runtimev1.TextGenerateScenarioSpec, stream bool) (textbehavior.SerializedRequest, error) { if spec == nil { return textbehavior.SerializedRequest{}, fmt.Errorf("Gemma 4 text behavior request is missing") @@ -118,6 +109,37 @@ func gemma4BehaviorTurnMessages(message *runtimev1.ChatMessage) ([]map[string]an role := strings.TrimSpace(message.GetRole()) switch role { case "assistant": + // @nimi-authority: rule.nimi.runtime.ai-provider.r119 + // A canonical turn can contain the assistant's calls followed by the + // external Host's results. Project contiguous roles without reordering + // those items into the engine's separate assistant/tool messages. + hasResults := false + for _, item := range message.GetTurnItems() { + hasResults = hasResults || item.GetToolResult() != nil + } + if hasResults { + segments := make([]*runtimev1.ChatMessage, 0) + for _, item := range message.GetTurnItems() { + itemRole := "assistant" + if item.GetToolResult() != nil { + itemRole = "tool" + } + if len(segments) == 0 || segments[len(segments)-1].Role != itemRole { + segments = append(segments, &runtimev1.ChatMessage{Role: itemRole}) + } + segment := segments[len(segments)-1] + segment.TurnItems = append(segment.TurnItems, item) + } + projected := make([]map[string]any, 0, len(segments)) + for _, segment := range segments { + values, err := gemma4BehaviorTurnMessages(segment) + if err != nil { + return nil, err + } + projected = append(projected, values...) + } + return projected, nil + } var text strings.Builder toolCalls := make([]map[string]any, 0) seenToolCall := false @@ -213,7 +235,7 @@ func gemma4ApplyTools(body map[string]any, spec *runtimev1.TextGenerateScenarioS if tool.GetInputSchema() != nil { schema = tool.GetInputSchema().AsMap() } - if _, err := gemma4CompileSchema(schema); err != nil { + if _, err := textbehavior.CompileJSONSchema(schema); err != nil { return gemma4InvalidRequest("tool input schema is invalid") } function := map[string]any{"name": tool.GetName(), "parameters": schema} @@ -260,7 +282,7 @@ func gemma4ApplyResponseFormat(body map[string]any, spec *runtimev1.TextGenerate return gemma4InvalidRequest("JSON Schema response format requires a schema") } schema := format.GetJsonSchema().AsMap() - if _, err := gemma4CompileSchema(schema); err != nil { + if _, err := textbehavior.CompileJSONSchema(schema); err != nil { return gemma4InvalidRequest("response JSON Schema is invalid") } name := strings.TrimSpace(format.GetSchemaName()) @@ -362,7 +384,7 @@ func Gemma4TextBehaviorStreamAssembler(spec *runtimev1.TextGenerateScenarioSpec) } for _, tool := range spec.GetTools() { if tool != nil && tool.GetInputSchema() != nil { - if _, err := gemma4CompileSchema(tool.GetInputSchema().AsMap()); err != nil { + if _, err := textbehavior.CompileJSONSchema(tool.GetInputSchema().AsMap()); err != nil { return nil, gemma4InvalidRequest("tool input schema is invalid") } } @@ -585,7 +607,7 @@ func gemma4ValidateStructuredItems(spec *runtimev1.TextGenerateScenarioSpec, ite if format.GetJsonSchema() == nil { return gemma4OutputInvalid() } - schema, err := gemma4CompileSchema(format.GetJsonSchema().AsMap()) + schema, err := textbehavior.CompileJSONSchema(format.GetJsonSchema().AsMap()) if err != nil || schema.Validate(instance) != nil { return gemma4OutputInvalid() } @@ -596,32 +618,12 @@ func gemma4ValidateStructuredItems(spec *runtimev1.TextGenerateScenarioSpec, ite } func gemma4ValidateToolArguments(tool *runtimev1.ToolSpec, arguments string) error { - var instance any - if err := gemma4DecodeJSON([]byte(strings.TrimSpace(arguments)), &instance); err != nil { - return gemma4ToolCallInvalid() - } - if _, ok := instance.(map[string]any); !ok { - return gemma4ToolCallInvalid() - } - if tool == nil || tool.GetInputSchema() == nil { - return nil - } - schema, err := gemma4CompileSchema(tool.GetInputSchema().AsMap()) - if err != nil || schema.Validate(instance) != nil { + if err := textbehavior.ValidateToolArguments(tool, arguments); err != nil { return gemma4ToolCallInvalid() } return nil } -func gemma4CompileSchema(document map[string]any) (*jsonschema.Schema, error) { - compiler := jsonschema.NewCompiler() - compiler.UseLoader(gemma4NoNetworkSchemaLoader{}) - if err := compiler.AddResource(gemma4SchemaResource, document); err != nil { - return nil, err - } - return compiler.Compile(gemma4SchemaResource) -} - func gemma4DeclaredTool(spec *runtimev1.TextGenerateScenarioSpec, name string) *runtimev1.ToolSpec { for _, tool := range spec.GetTools() { if tool != nil && tool.GetName() == name { @@ -667,12 +669,12 @@ func gemma4Int64(value any) int64 { func gemma4TextContent(value any) string { switch typed := value.(type) { case string: - return strings.TrimSpace(typed) + return typed case []any: var result strings.Builder for _, raw := range typed { part, _ := raw.(map[string]any) - if text := gemma4String(part["text"]); text != "" { + if text := gemma4RawString(part["text"]); text != "" { result.WriteString(text) } } diff --git a/runtime/internal/capabilitydriver/llama_text_behavior_gemma4_test.go b/runtime/internal/capabilitydriver/llama_text_behavior_gemma4_test.go index 71f51b994a..d8c6dd55d0 100644 --- a/runtime/internal/capabilitydriver/llama_text_behavior_gemma4_test.go +++ b/runtime/internal/capabilitydriver/llama_text_behavior_gemma4_test.go @@ -94,6 +94,35 @@ func TestGemma4BehaviorSerializerLowersNamedChoiceToOneRequiredTool(t *testing.T } } +func TestGemma4BehaviorSerializerProjectsResultsInAnOrderedAssistantTurn(t *testing.T) { + items := []*runtimev1.TextTurnItem{{Item: &runtimev1.TextTurnItem_Output{Output: &runtimev1.TextOutputItem{Item: &runtimev1.TextOutputItem_Text{Text: &runtimev1.TextOutputText{Text: "Checking.\n"}}}}}} + for _, id := range []string{"first", "second"} { + items = append(items, &runtimev1.TextTurnItem{Item: &runtimev1.TextTurnItem_Output{Output: &runtimev1.TextOutputItem{Item: &runtimev1.TextOutputItem_ToolCall{ToolCall: &runtimev1.ToolCall{Id: id, Name: "weather", ArgumentsJson: `{"city":"Paris"}`}}}}}) + } + for _, id := range []string{"first", "second"} { + items = append(items, &runtimev1.TextTurnItem{Item: &runtimev1.TextTurnItem_ToolResult{ToolResult: &runtimev1.ToolResult{ToolCallId: id, ToolName: "weather", Result: structpb.NewStringValue(id)}}}) + } + spec := &runtimev1.TextGenerateScenarioSpec{ + Input: []*runtimev1.ChatMessage{{Role: "user", Content: "Compare weather."}, {Role: "assistant", TurnItems: items}}, + Tools: []*runtimev1.ToolSpec{gemma4ToolForTest(t, "weather")}, + } + serialized, err := Gemma4TextBehaviorRequestSerializer(spec, true) + if err != nil { + t.Fatal(err) + } + var body struct { + Messages []map[string]any `json:"messages"` + } + if err := json.Unmarshal(serialized.Payload, &body); err != nil { + t.Fatal(err) + } + if len(body.Messages) != 4 || body.Messages[1]["role"] != "assistant" || body.Messages[1]["content"] != "Checking.\n" || + len(body.Messages[1]["tool_calls"].([]any)) != 2 || body.Messages[2]["role"] != "tool" || body.Messages[2]["tool_call_id"] != "first" || + body.Messages[3]["role"] != "tool" || body.Messages[3]["tool_call_id"] != "second" || len(spec.Input[1].TurnItems) != 5 { + t.Fatalf("ordered result projection = %+v", body.Messages) + } +} + func TestGemma4BehaviorParsersNormalizeParallelCallsAndHiddenReasoning(t *testing.T) { spec := &runtimev1.TextGenerateScenarioSpec{ Input: []*runtimev1.ChatMessage{{Role: "user", Content: "Compare weather."}}, @@ -218,6 +247,32 @@ func TestGemma4BehaviorStreamAcceptsHiddenReasoningThenUsageTail(t *testing.T) { } } +func TestGemma4BehaviorStreamPreservesWhitespaceAcrossTokenBoundaries(t *testing.T) { + spec := &runtimev1.TextGenerateScenarioSpec{Input: []*runtimev1.ChatMessage{{Role: "user", Content: "Answer."}}} + assembler, err := Gemma4TextBehaviorStreamAssembler(spec) + if err != nil { + t.Fatal(err) + } + var streamed strings.Builder + for _, content := range []string{"Hello", " ", "world", "\n", " next line "} { + deltas, err := assembler.Append(gemma4ChunkForTest(t, map[string]any{"content": content}, nil)) + if err != nil { + t.Fatal(err) + } + for _, delta := range deltas { + streamed.WriteString(delta.Text) + } + } + if _, err := assembler.Append(gemma4ChunkForTest(t, map[string]any{}, "stop")); err != nil { + t.Fatal(err) + } + result, err := assembler.Finish() + const expected = "Hello world\n next line " + if err != nil || len(result.Items) != 1 || result.Items[0].Text != expected || streamed.String() != expected { + t.Fatalf("whitespace changed: stream=%q result=%+v err=%v", streamed.String(), result, err) + } +} + func TestGemma4StructuredOutputValidatesCapturedSchema(t *testing.T) { schema, err := structpb.NewStruct(map[string]any{ "type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}, diff --git a/runtime/internal/grpcserver/local_app_text_transport_test.go b/runtime/internal/grpcserver/local_app_text_transport_test.go new file mode 100644 index 0000000000..d4434bedf9 --- /dev/null +++ b/runtime/internal/grpcserver/local_app_text_transport_test.go @@ -0,0 +1,66 @@ +package grpcserver + +import ( + "context" + "testing" + + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" +) + +func textBusinessJSONRequest(t *testing.T) *runtimev1.StreamLocalAppTextTurnRequest { + t.Helper() + schema, err := structpb.NewStruct(map[string]any{"type": "object", "properties": map[string]any{"subject": map[string]any{"type": "string"}}}) + if err != nil { + t.Fatal(err) + } + result, err := structpb.NewValue(map[string]any{"subject": "email subject", "token": "application-owned value"}) + if err != nil { + t.Fatal(err) + } + return &runtimev1.StreamLocalAppTextTurnRequest{ + Messages: []*runtimev1.LocalAppTextCandidateMessage{ + {Role: "user", Text: "read the selected document"}, + {Role: "assistant", TurnItems: []*runtimev1.TextTurnItem{ + {Item: &runtimev1.TextTurnItem_Output{Output: &runtimev1.TextOutputItem{Item: &runtimev1.TextOutputItem_ToolCall{ToolCall: &runtimev1.ToolCall{Id: "call-1", Name: "read", ArgumentsJson: `{}`}}}}}, + {Item: &runtimev1.TextTurnItem_ToolResult{ToolResult: &runtimev1.ToolResult{ToolCallId: "call-1", ToolName: "read", Result: result}}}, + }}, + }, + Tools: []*runtimev1.ToolSpec{{Kind: runtimev1.ToolSpecKind_TOOL_SPEC_KIND_FUNCTION, Name: "read", InputSchema: schema}}, + ResponseFormat: &runtimev1.ResponseFormat{Kind: runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_SCHEMA, JsonSchema: schema}, + } +} + +func TestProtectedLocalAppTextBusinessJSONDoesNotAssertCallerAuthority(t *testing.T) { + request := textBusinessJSONRequest(t) + if protectedLocalAppRequestHasCallerAssertionForMethod(context.Background(), request, protectedStreamTextTurnMethod) { + t.Fatal("declared schemas and tool results were treated as caller identity") + } + execute := &runtimev1.ExecuteLocalAppScenarioRequest{Spec: &runtimev1.ExecuteLocalAppScenarioRequest_TextGenerate{TextGenerate: request}} + if protectedLocalAppRequestHasCallerAssertionForMethod(context.Background(), execute, protectedExecuteLocalAppScenarioMethod) { + t.Fatal("synchronous text business JSON was rejected") + } + if !protectedLocalAppRequestHasCallerAssertion(context.Background(), request) { + t.Fatal("text data exception leaked outside the exact text operation") + } + request.Tools[0].ProviderArgs = request.Tools[0].InputSchema + if !protectedLocalAppRequestHasCallerAssertionForMethod(context.Background(), request, protectedStreamTextTurnMethod) { + t.Fatal("the schema exception admitted another provider field") + } +} + +func TestProtectedLocalAppTextUnknownFieldsStillFailClosed(t *testing.T) { + unknown := protowire.AppendVarint(protowire.AppendTag(nil, 199, protowire.VarintType), 1) + request := textBusinessJSONRequest(t) + request.ProtoReflect().SetUnknown(unknown) + if !protectedLocalAppRequestHasCallerAssertionForMethod(context.Background(), request, protectedStreamTextTurnMethod) { + t.Fatal("unknown protobuf fields were silently accepted") + } + request = proto.Clone(textBusinessJSONRequest(t)).(*runtimev1.StreamLocalAppTextTurnRequest) + request.Tools[0].InputSchema.ProtoReflect().SetUnknown(unknown) + if !protectedLocalAppRequestHasCallerAssertionForMethod(context.Background(), request, protectedStreamTextTurnMethod) { + t.Fatal("business JSON bypassed unknown protobuf field validation") + } +} diff --git a/runtime/internal/grpcserver/local_app_transport.go b/runtime/internal/grpcserver/local_app_transport.go index 20411a7771..16eaad4801 100644 --- a/runtime/internal/grpcserver/local_app_transport.go +++ b/runtime/internal/grpcserver/local_app_transport.go @@ -368,10 +368,9 @@ func newUnaryProtectedLocalAppTransportInterceptor(admissions ...protectedLocalA type protectedLocalAppServerStream struct { grpc.ServerStream - ctx context.Context - allowRealtimeGeneration bool - connection *protectedlocal.LocalAppConnection - method string + ctx context.Context + connection *protectedlocal.LocalAppConnection + method string } func (stream *protectedLocalAppServerStream) Context() context.Context { return stream.ctx } @@ -380,7 +379,7 @@ func (stream *protectedLocalAppServerStream) RecvMsg(message any) error { if err := stream.ServerStream.RecvMsg(message); err != nil { return err } - if protectedLocalAppRequestHasCallerAssertionWithGeneration(stream.ctx, message, stream.allowRealtimeGeneration) { + if protectedLocalAppRequestHasCallerAssertionForMethod(stream.ctx, message, stream.method) { return grpcerr.WithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_LOCAL_APP_ACCESS_DENIED) } if err := authorizeProtectedLocalAppRealtimeResource(stream.ctx, stream.connection, stream.method, message); err != nil { @@ -424,11 +423,10 @@ func newStreamProtectedLocalAppTransportInterceptor(admissions ...protectedLocal return err } return handler(service, &protectedLocalAppServerStream{ - ServerStream: stream, - ctx: authorizedContext, - allowRealtimeGeneration: protectedLocalAppMethodAllowsRealtimeGeneration(info.FullMethod), - connection: connection, - method: info.FullMethod, + ServerStream: stream, + ctx: authorizedContext, + connection: connection, + method: info.FullMethod, }) } } @@ -680,19 +678,16 @@ func protectedLocalAppStreamIngress(method string) localappop.Ingress { } func protectedLocalAppRequestHasCallerAssertion(ctx context.Context, request any) bool { - return protectedLocalAppRequestHasCallerAssertionWithGeneration(ctx, request, false) + return protectedLocalAppRequestHasCallerAssertionForMethod(ctx, request, "") } func protectedLocalAppRequestHasCallerAssertionForMethod(ctx context.Context, request any, method string) bool { - return protectedLocalAppRequestHasCallerAssertionWithGeneration(ctx, request, protectedLocalAppMethodAllowsRealtimeGeneration(method)) -} - -func protectedLocalAppRequestHasCallerAssertionWithGeneration(ctx context.Context, request any, allowRealtimeGeneration bool) bool { if protectedLocalAppMetadataHasCallerAssertion(ctx) { return true } message, ok := request.(proto.Message) - return !ok || protectedLocalAppMessageHasCallerAssertion(message.ProtoReflect(), allowRealtimeGeneration) + allowTextJSON := method == protectedStreamTextTurnMethod || method == protectedExecuteLocalAppScenarioMethod + return !ok || protectedLocalAppMessageHasCallerAssertion(message.ProtoReflect(), protectedLocalAppMethodAllowsRealtimeGeneration(method), allowTextJSON, false) } func protectedLocalAppMetadataHasCallerAssertion(ctx context.Context) bool { @@ -705,17 +700,19 @@ func protectedLocalAppMetadataHasCallerAssertion(ctx context.Context) bool { return false } -func protectedLocalAppMessageHasCallerAssertion(message protoreflect.Message, allowRealtimeGeneration bool) bool { +// @nimi-authority: rule.nimi.runtime.ai-provider.local-app-text-behaviors +func protectedLocalAppMessageHasCallerAssertion(message protoreflect.Message, allowRealtimeGeneration, allowTextJSON, businessJSON bool) bool { if !message.IsValid() || len(message.GetUnknown()) != 0 { return true } found := false message.Range(func(field protoreflect.FieldDescriptor, value protoreflect.Value) bool { - if protectedLocalAppCallerAssertionFieldExceptRealtimeGeneration(string(field.Name()), allowRealtimeGeneration) || - protectedLocalAppCallerAssertionFieldExceptRealtimeGeneration(field.JSONName(), allowRealtimeGeneration) { + if !businessJSON && (protectedLocalAppCallerAssertionFieldExceptRealtimeGeneration(string(field.Name()), allowRealtimeGeneration) || + protectedLocalAppCallerAssertionFieldExceptRealtimeGeneration(field.JSONName(), allowRealtimeGeneration)) { found = true return false } + childJSON := businessJSON || (allowTextJSON && protectedLocalAppTextJSONField(field)) switch { case field.IsList(): if field.Message() == nil { @@ -723,25 +720,25 @@ func protectedLocalAppMessageHasCallerAssertion(message protoreflect.Message, al } list := value.List() for index := 0; index < list.Len(); index++ { - if protectedLocalAppMessageHasCallerAssertion(list.Get(index).Message(), allowRealtimeGeneration) { + if protectedLocalAppMessageHasCallerAssertion(list.Get(index).Message(), allowRealtimeGeneration, allowTextJSON, childJSON) { found = true return false } } case field.IsMap(): value.Map().Range(func(key protoreflect.MapKey, entry protoreflect.Value) bool { - if field.MapKey().Kind() == protoreflect.StringKind && protectedLocalAppCallerAssertionField(key.String()) { + if !childJSON && field.MapKey().Kind() == protoreflect.StringKind && protectedLocalAppCallerAssertionField(key.String()) { found = true return false } - if field.MapValue().Message() != nil && protectedLocalAppMessageHasCallerAssertion(entry.Message(), allowRealtimeGeneration) { + if field.MapValue().Message() != nil && protectedLocalAppMessageHasCallerAssertion(entry.Message(), allowRealtimeGeneration, allowTextJSON, childJSON) { found = true return false } return true }) case field.Message() != nil: - if protectedLocalAppMessageHasCallerAssertion(value.Message(), allowRealtimeGeneration) { + if protectedLocalAppMessageHasCallerAssertion(value.Message(), allowRealtimeGeneration, allowTextJSON, childJSON) { found = true } } @@ -750,6 +747,19 @@ func protectedLocalAppMessageHasCallerAssertion(message protoreflect.Message, al return found } +func protectedLocalAppTextJSONField(field protoreflect.FieldDescriptor) bool { + switch field.ContainingMessage().FullName() { + case "nimi.runtime.v1.ToolSpec": + return field.Name() == "input_schema" + case "nimi.runtime.v1.ToolResult": + return field.Name() == "result" + case "nimi.runtime.v1.ResponseFormat": + return field.Name() == "json_schema" + default: + return false + } +} + func protectedLocalAppCallerAssertionFieldExceptRealtimeGeneration(value string, allowRealtimeGeneration bool) bool { normalized := strings.NewReplacer("-", "", "_", "", ".", "").Replace(strings.ToLower(strings.TrimSpace(value))) return !(allowRealtimeGeneration && normalized == "generation") && protectedLocalAppCallerAssertionField(value) diff --git a/runtime/internal/nimillm/backend.go b/runtime/internal/nimillm/backend.go index 7f5bf7ec11..fa7d6b0503 100644 --- a/runtime/internal/nimillm/backend.go +++ b/runtime/internal/nimillm/backend.go @@ -465,26 +465,16 @@ func (b *Backend) applyAuthenticationHeaders(request *http.Request) { request.Header.Set("Authorization", "Bearer "+apiKey) } -// GenerateText sends a non-streaming chat completion request. The OpenAI-compatible -// path maps tools, tool choice, structured response formats, and the standard -// advanced-sampling parameters and parses returned tool calls. The Anthropic and -// Codex paths fail closed on tools / structured output until they are wired. +// GenerateText sends one primitive non-streaming text request. Optional text +// behaviors execute through the captured adapter transport. func (b *Backend) GenerateText(ctx context.Context, modelID string, input []*runtimev1.ChatMessage, systemPrompt string, temperature float32, topP float32, maxTokens int32, params textGenParams) (string, []*runtimev1.ToolCall, *runtimev1.UsageStats, runtimev1.FinishReason, error) { - if err := unsupportedTextBehaviorSurface(ctx, b, modelID, params, input, false); err != nil { + if err := unsupportedTextBehaviorSurface(params, input); err != nil { return "", nil, nil, runtimev1.FinishReason_FINISH_REASON_ERROR, err } if b.supportsAnthropicMessages() { - // Anthropic Messages has no native JSON response_format; structured output - // stays fail-closed while tools execute through tool_use blocks. - if params.wantsStructuredOutput() { - return "", nil, nil, runtimev1.FinishReason_FINISH_REASON_ERROR, textBehaviorUnsupportedError() - } return b.generateTextAnthropicMessages(ctx, modelID, input, systemPrompt, temperature, topP, maxTokens, params) } if b.supportsCodexResponses() { - if params.requestsToolUse() || params.wantsStructuredOutput() { - return "", nil, nil, runtimev1.FinishReason_FINISH_REASON_ERROR, textBehaviorUnsupportedError() - } text, usage, finish, err := b.generateTextCodexResponses(ctx, modelID, input, systemPrompt, temperature, topP, maxTokens, params) return text, nil, usage, finish, err } @@ -628,7 +618,7 @@ func (b *Backend) StreamGenerateText(ctx context.Context, modelID string, input // StreamGenerateTextRich sends a streaming chat completion request while // preserving provider reasoning deltas as a separate typed channel. func (b *Backend) StreamGenerateTextRich(ctx context.Context, modelID string, input []*runtimev1.ChatMessage, systemPrompt string, temperature float32, topP float32, maxTokens int32, params textGenParams, handler TextStreamEventHandler) (*runtimev1.UsageStats, runtimev1.FinishReason, error) { - if err := unsupportedTextBehaviorSurface(ctx, b, modelID, params, input, true); err != nil { + if err := unsupportedTextBehaviorSurface(params, input); err != nil { return nil, runtimev1.FinishReason_FINISH_REASON_ERROR, err } if b.supportsAnthropicMessages() { diff --git a/runtime/internal/nimillm/backend_errors.go b/runtime/internal/nimillm/backend_errors.go index 40b9ab3c77..dd42084b97 100644 --- a/runtime/internal/nimillm/backend_errors.go +++ b/runtime/internal/nimillm/backend_errors.go @@ -124,6 +124,7 @@ func classifyProviderBadRequest(providerMessage string) (codes.Code, runtimev1.R "no such model", "model does not exist", "model is not available", + "model is not supported when using codex with a chatgpt account", "invalid model", ) if modelNotFound { diff --git a/runtime/internal/nimillm/backend_errors_test.go b/runtime/internal/nimillm/backend_errors_test.go index 830588d846..8ff7833ab9 100644 --- a/runtime/internal/nimillm/backend_errors_test.go +++ b/runtime/internal/nimillm/backend_errors_test.go @@ -296,6 +296,15 @@ func TestMapProviderHTTPError_BadRequestModelNotFound(t *testing.T) { assertProviderMessageAbsent(t, metadata) } +func TestMapProviderHTTPError_CodexModelUnavailableForAccount(t *testing.T) { + err := MapProviderHTTPError(400, map[string]any{"detail": "The 'gpt-5.6-sol-wm' model is not supported when using Codex with a ChatGPT account."}) + reason, ok := grpcerr.ExtractReasonCode(err) + if !ok || reason != runtimev1.ReasonCode_AI_MODEL_NOT_FOUND || status.Code(err) != codes.NotFound { + t.Fatalf("Codex model rejection = %v", err) + } + assertProviderMessageAbsent(t, extractErrorInfoMetadata(err)) +} + func TestMapProviderHTTPError_BadRequestMarketAppNotActivated(t *testing.T) { err := MapProviderHTTPError(400, map[string]any{ "error": map[string]any{ diff --git a/runtime/internal/nimillm/backend_tools.go b/runtime/internal/nimillm/backend_tools.go index 6692ad90ec..612dd976df 100644 --- a/runtime/internal/nimillm/backend_tools.go +++ b/runtime/internal/nimillm/backend_tools.go @@ -1,7 +1,6 @@ package nimillm import ( - "context" "encoding/json" "strings" @@ -87,15 +86,6 @@ func (p textGenParams) requestsToolUse() bool { return p.hasTools() || p.toolChoice != runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_UNSPECIFIED || strings.TrimSpace(p.toolChoiceName) != "" } -func (p textGenParams) hasProviderTools() bool { - for _, tool := range p.tools { - if tool != nil && tool.GetKind() == runtimev1.ToolSpecKind_TOOL_SPEC_KIND_PROVIDER { - return true - } - } - return false -} - // wantsStructuredOutput reports whether a non-text response format was requested. func (p textGenParams) wantsStructuredOutput() bool { if p.responseFormat == nil { @@ -279,40 +269,31 @@ func providerRawChunksUnsupportedError() error { }) } -// unsupportedTextBehaviorSurface admits Tool Use or Structured Output only -// with one request-scoped exact adapter proof. Current stream serializers have -// no registered ordered Tool/Structured mapping and therefore stay closed. -func unsupportedTextBehaviorSurface(ctx context.Context, backend *Backend, modelID string, params textGenParams, input []*runtimev1.ChatMessage, stream bool) error { +// Primitive Backend entrypoints accept base text only. Optional text behaviors +// execute through the captured adapter transport. +// @nimi-authority: rule.nimi.runtime.ai-provider.r119 +// @nimi-authority: rule.nimi.runtime.ai-provider.r123 +func unsupportedTextBehaviorSurface(params textGenParams, input []*runtimev1.ChatMessage) error { if params.includeRawChunks { return providerRawChunksUnsupportedError() } turnToolUse, turnReasoning := textMessagesRequestBehavior(input) - toolUse := params.requestsToolUse() || turnToolUse - structured := params.wantsStructuredOutput() - if turnReasoning || params.hasProviderTools() { - return textBehaviorUnsupportedError() - } - if !toolUse && !structured { - return nil - } - admission := textBehaviorAdmissionFromContext(ctx) - if admission == nil || !textBehaviorAdmissionMatchesTarget(admission, backend, modelID) || - (toolUse && !admission.ToolUse) || (structured && !admission.StructuredOutput) || - (toolUse && structured && !admission.ToolStructuredCombination) || - (stream && !admission.Stream) || (!stream && !admission.Sync && !admission.Async) { - return textBehaviorUnsupportedError() - } - if stream { + if params.requestsToolUse() || params.wantsStructuredOutput() || turnToolUse || turnReasoning { return textBehaviorUnsupportedError() } return nil } -func textBehaviorAdmissionMatchesTarget(admission *TextBehaviorAdmission, backend *Backend, modelID string) bool { - if admission == nil || backend == nil || strings.TrimSpace(modelID) == "" || modelID != strings.TrimSpace(modelID) { - return false +func textMessagesRequestBehavior(input []*runtimev1.ChatMessage) (toolUse bool, reasoning bool) { + for _, message := range input { + for _, item := range message.GetTurnItems() { + if item.GetToolResult() != nil || item.GetOutput().GetToolCall() != nil { + toolUse = true + } + if item.GetOutput().GetReasoningSummary() != nil || item.GetOutput().GetReasoningContinuity() != nil { + reasoning = true + } + } } - provider := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(backend.Name)), "cloud-") - provider = ResolveProviderAlias(provider) - return provider == admission.Provider && modelID == admission.ProviderModelID + return toolUse, reasoning } diff --git a/runtime/internal/nimillm/backend_tools_test.go b/runtime/internal/nimillm/backend_tools_test.go index 94a53df286..6de55d50bf 100644 --- a/runtime/internal/nimillm/backend_tools_test.go +++ b/runtime/internal/nimillm/backend_tools_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "sync/atomic" "testing" runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" @@ -15,11 +16,6 @@ func TestBuildTextGenParamsCarriesTopK(t *testing.T) { spec := &runtimev1.TextGenerateScenarioSpec{ TopK: testInt32(19), IncludeRawChunks: true, - Tools: []*runtimev1.ToolSpec{{ - Name: "web_search", - Kind: runtimev1.ToolSpecKind_TOOL_SPEC_KIND_PROVIDER, - ProviderToolId: "test.web_search", - }}, } params := BuildTextGenParams(spec) if params.topK != 19 { @@ -28,9 +24,6 @@ func TestBuildTextGenParamsCarriesTopK(t *testing.T) { if !params.includeRawChunks { t.Fatal("expected includeRawChunks to be preserved") } - if !params.hasProviderTools() { - t.Fatal("expected provider tool detection") - } } func TestOpenAIToolsPayloadDoesNotCoerceProviderTools(t *testing.T) { @@ -95,199 +88,72 @@ func TestGenerateTextOpenAIRawChunksFailClosed(t *testing.T) { } } -func TestGenerateTextOpenAIToolCallsAndStructuredOutputRequireExactAdapterAdmission(t *testing.T) { - var captured map[string]any - requestCount := 0 - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestCount++ - captured = decodeJSONBodyForBackendMediaTest(t, r) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Paris\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":4,"completion_tokens":2}}`)) - })) - defer server.Close() - - backend := newBackend("cloud-openai", server.URL, "", nil, 0, server.Client().Transport, false, true) - if backend == nil { - t.Fatal("expected backend") - } - +func TestPrimitiveTextGenerationRejectsOptionalBehaviors(t *testing.T) { schema, err := structpb.NewStruct(map[string]any{"type": "object"}) if err != nil { - t.Fatalf("schema: %v", err) - } - params := textGenParams{ - tools: []*runtimev1.ToolSpec{{ - Name: "get_weather", - Description: "look up the weather", - InputSchema: schema, - }}, - toolChoice: runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_REQUIRED, - responseFormat: &runtimev1.ResponseFormat{Kind: runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_OBJECT}, - seed: 7, - topK: 41, - } - - input := []*runtimev1.ChatMessage{{Role: "user", Content: "weather in Paris"}} - if _, _, _, _, err := backend.GenerateText( - context.Background(), "gpt-4o-mini", input, "", 0, 0, 0, params, - ); textBehaviorReasonForTest(err) != runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED || requestCount != 0 { - t.Fatalf("unadmitted tool/structured request = reason=%v requests=%d err=%v", textBehaviorReasonForTest(err), requestCount, err) - } - ctx := WithTextBehaviorAdmission(context.Background(), &TextBehaviorAdmission{ - AdapterID: "openai-tools-json", Version: "1", Provider: "openai", ProviderModelID: "gpt-4o-mini", ToolUse: true, StructuredOutput: true, - Sync: true, ToolStructuredCombination: true, - }) - text, toolCalls, _, finish, err := backend.GenerateText( - ctx, - "gpt-4o-mini", - input, - "", - 0, 0, 0, - params, - ) - if err != nil { - t.Fatalf("generate text: %v", err) - } - if text != "" { - t.Fatalf("expected empty text for tool-call response, got %q", text) - } - if len(toolCalls) != 1 { - t.Fatalf("expected 1 tool call, got %d", len(toolCalls)) - } - if toolCalls[0].GetName() != "get_weather" { - t.Fatalf("unexpected tool name: %q", toolCalls[0].GetName()) - } - if toolCalls[0].GetId() != "call_1" { - t.Fatalf("unexpected tool id: %q", toolCalls[0].GetId()) - } - if toolCalls[0].GetArgumentsJson() != `{"city":"Paris"}` { - t.Fatalf("unexpected tool args: %q", toolCalls[0].GetArgumentsJson()) - } - if finish != runtimev1.FinishReason_FINISH_REASON_TOOL_CALL { - t.Fatalf("expected tool-call finish reason, got %v", finish) - } - - tools, ok := captured["tools"].([]any) - if !ok || len(tools) != 1 { - t.Fatalf("expected 1 request tool, got=%T len=%d", captured["tools"], len(tools)) - } - toolObj, _ := tools[0].(map[string]any) - if toolObj["type"] != "function" { - t.Fatalf("expected function tool, got %v", toolObj["type"]) - } - function, _ := toolObj["function"].(map[string]any) - if function["name"] != "get_weather" { - t.Fatalf("unexpected request tool name: %v", function["name"]) - } - if captured["tool_choice"] != "required" { - t.Fatalf("expected required tool_choice, got %v", captured["tool_choice"]) - } - responseFormat, ok := captured["response_format"].(map[string]any) - if !ok || responseFormat["type"] != "json_object" { - t.Fatalf("expected json_object response_format, got %v", captured["response_format"]) - } - if captured["seed"] == nil { - t.Fatal("expected seed in request") - } - if captured["top_k"] != float64(41) { - t.Fatalf("expected top_k pass-through, got %v", captured["top_k"]) - } -} - -func TestGenerateTextAnthropicFailsClosedOnStructuredOutput(t *testing.T) { - backend := newBackend("cloud-anthropic", "https://api.anthropic.com", "", nil, 0, nil, false, true) - if backend == nil { - t.Fatal("expected backend") - } - params := textGenParams{ - responseFormat: &runtimev1.ResponseFormat{Kind: runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_OBJECT}, - } - if _, _, _, _, err := backend.GenerateText( - context.Background(), - "claude-sonnet-4-6", - []*runtimev1.ChatMessage{{Role: "user", Content: "weather"}}, - "", - 0, 0, 0, - params, - ); textBehaviorReasonForTest(err) != runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED { - t.Fatalf("expected Anthropic path to fail typed on unadmitted structured output: %v", err) - } -} - -func TestGenerateTextAnthropicToolCallsRequireExactAdapterAdmission(t *testing.T) { - var captured map[string]any - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - captured = decodeJSONBodyForBackendMediaTest(t, r) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"content":[{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"Paris"}}],"stop_reason":"tool_use","usage":{"input_tokens":4,"output_tokens":2}}`)) - })) - defer server.Close() - - backend := newBackend("cloud-anthropic", server.URL, "", nil, 0, server.Client().Transport, false, true) - if backend == nil { - t.Fatal("expected backend") - } - schema, _ := structpb.NewStruct(map[string]any{"type": "object"}) - params := textGenParams{ - tools: []*runtimev1.ToolSpec{{Name: "get_weather", Description: "weather", InputSchema: schema}}, - toolChoice: runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_AUTO, - topK: 27, - } - - ctx := WithTextBehaviorAdmission(context.Background(), &TextBehaviorAdmission{ - AdapterID: "anthropic-tools", Version: "1", Provider: "anthropic", ProviderModelID: "claude-sonnet-4-6", ToolUse: true, Sync: true, - }) - _, toolCalls, _, finish, err := backend.GenerateText( - ctx, - "claude-sonnet-4-6", - []*runtimev1.ChatMessage{{Role: "user", Content: "weather in Paris"}}, - "", - 0, 0, 0, - params, - ) - if err != nil { - t.Fatalf("generate text: %v", err) - } - if len(toolCalls) != 1 { - t.Fatalf("expected 1 tool call, got %d", len(toolCalls)) - } - if toolCalls[0].GetName() != "get_weather" || toolCalls[0].GetId() != "toolu_1" { - t.Fatalf("unexpected tool call: %+v", toolCalls[0]) - } - if toolCalls[0].GetArgumentsJson() != `{"city":"Paris"}` { - t.Fatalf("unexpected tool args: %q", toolCalls[0].GetArgumentsJson()) - } - if finish != runtimev1.FinishReason_FINISH_REASON_TOOL_CALL { - t.Fatalf("expected tool-call finish, got %v", finish) - } - tools, ok := captured["tools"].([]any) - if !ok || len(tools) != 1 { - t.Fatalf("expected 1 request tool, got %T", captured["tools"]) - } - toolObj, _ := tools[0].(map[string]any) - if toolObj["name"] != "get_weather" || toolObj["input_schema"] == nil { - t.Fatalf("unexpected request tool: %+v", toolObj) - } - if captured["top_k"] != float64(27) { - t.Fatalf("expected top_k pass-through, got %v", captured["top_k"]) - } -} - -func TestStreamGenerateTextToolUseStaysClosedWithoutNativeOrderedSerializer(t *testing.T) { - backend := newBackend("cloud-openai", "https://api.openai.test", "", nil, 0, nil, false, true) - ctx := WithTextBehaviorAdmission(context.Background(), &TextBehaviorAdmission{ - AdapterID: "openai-tools-stream", Version: "1", Provider: "openai", ProviderModelID: "gpt-4o-mini", ToolUse: true, Stream: true, - }) - _, _, err := backend.StreamGenerateText( - ctx, - "gpt-4o-mini", - []*runtimev1.ChatMessage{{Role: "user", Content: "weather"}}, - "", 0, 0, 0, - textGenParams{tools: []*runtimev1.ToolSpec{{Kind: runtimev1.ToolSpecKind_TOOL_SPEC_KIND_FUNCTION, Name: "weather"}}}, - func(string) error { return nil }, - ) - if textBehaviorReasonForTest(err) != runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED { - t.Fatalf("stream tool behavior error = %v", err) + t.Fatal(err) + } + userInput := []*runtimev1.ChatMessage{{Role: "user", Content: "weather in Paris"}} + cases := []struct { + name string + params textGenParams + input []*runtimev1.ChatMessage + }{ + {name: "function tools", params: textGenParams{tools: []*runtimev1.ToolSpec{{Name: "weather", Kind: runtimev1.ToolSpecKind_TOOL_SPEC_KIND_FUNCTION, InputSchema: schema}}}}, + {name: "tool choice", params: textGenParams{toolChoice: runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_NONE}}, + {name: "structured output", params: textGenParams{responseFormat: &runtimev1.ResponseFormat{Kind: runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_OBJECT}}}, + {name: "tool transcript", input: []*runtimev1.ChatMessage{userInput[0], canonicalAssistantToolMessage("call-1", "weather", `{}`), canonicalToolResultMessage(t, "call-1", "weather", "sunny")}}, + { + name: "reasoning summary", + input: []*runtimev1.ChatMessage{{Role: "assistant", TurnItems: []*runtimev1.TextTurnItem{{ + Item: &runtimev1.TextTurnItem_Output{Output: &runtimev1.TextOutputItem{ + Item: &runtimev1.TextOutputItem_ReasoningSummary{ReasoningSummary: &runtimev1.ReasoningSummary{Text: "summary"}}, + }}, + }}}}, + }, + { + name: "reasoning continuity", + input: []*runtimev1.ChatMessage{{Role: "assistant", TurnItems: []*runtimev1.TextTurnItem{{ + Item: &runtimev1.TextTurnItem_Output{Output: &runtimev1.TextOutputItem{ + Item: &runtimev1.TextOutputItem_ReasoningContinuity{ReasoningContinuity: &runtimev1.ReasoningContinuityCarrier{ + Kind: "test.continuity", Version: 1, Payload: []byte("opaque"), + }}, + }}, + }}}}, + }, + } + for _, provider := range []struct{ name, model string }{{"openai", "gpt-4o-mini"}, {"anthropic", "claude-sonnet-4-6"}, {"openai_codex", "gpt-5.6-sol"}} { + t.Run(provider.name, func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + http.Error(w, "unexpected primitive behavior dispatch", http.StatusBadRequest) + })) + defer server.Close() + backend := newBackend("cloud-"+provider.name, server.URL, "", nil, 0, server.Client().Transport, false, true) + if backend == nil { + t.Fatal("expected backend") + } + for _, test := range cases { + for _, mode := range []string{"sync", "stream"} { + t.Run(test.name+"/"+mode, func(t *testing.T) { + input := test.input + if input == nil { + input = userInput + } + var err error + if mode == "sync" { + _, _, _, _, err = backend.GenerateText(context.Background(), provider.model, input, "", 0, 0, 0, test.params) + } else { + _, _, err = backend.StreamGenerateText(context.Background(), provider.model, input, "", 0, 0, 0, test.params, func(string) error { return nil }) + } + if textBehaviorReasonForTest(err) != runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED || requests.Load() != 0 { + t.Fatalf("primitive optional behavior = %v, provider requests = %d", err, requests.Load()) + } + }) + } + } + }) } } diff --git a/runtime/internal/nimillm/cloud_text_behavior_transport.go b/runtime/internal/nimillm/cloud_text_behavior_transport.go new file mode 100644 index 0000000000..b90c26d33f --- /dev/null +++ b/runtime/internal/nimillm/cloud_text_behavior_transport.go @@ -0,0 +1,125 @@ +package nimillm + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "github.com/nimiplatform/nimi/runtime/internal/grpcerr" + "github.com/nimiplatform/nimi/runtime/internal/textbehavior" + "google.golang.org/grpc/codes" +) + +// @nimi-authority: rule.nimi.runtime.ai-provider.anthropic-sonnet46-text-behaviors +// @nimi-authority: rule.nimi.runtime.ai-provider.codex-text-behaviors +// The Host supplies the exact credential-bearing target only for this call. +// Hooks were selected and their request serialized before Job publication. +func (p *CloudProvider) ExecuteTextBehaviorWithTarget( + ctx context.Context, modelID string, target *RemoteTarget, + invocation *textbehavior.Invocation, serialized textbehavior.SerializedRequest, + onDelta func(textbehavior.OrderedDelta) error, +) (textbehavior.NormalizedResult, error) { + if target == nil || invocation == nil { + return textbehavior.NormalizedResult{}, grpcerr.WithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED) + } + path := "/v1/messages" + wireStream := onDelta != nil + switch target.ProviderType { + case "anthropic": + case "openai_codex": + path, wireStream = codexResponsesPath, true + default: + return textbehavior.NormalizedResult{}, grpcerr.WithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED) + } + backend, resolvedModelID := p.resolveBackendForTarget(modelID, target) + if backend == nil { + return textbehavior.NormalizedResult{}, grpcerr.WithReasonCode(codes.Unavailable, runtimev1.ReasonCode_AI_PROVIDER_UNAVAILABLE) + } + var body map[string]json.RawMessage + if json.Unmarshal(serialized.Payload, &body) != nil || body == nil { + return textbehavior.NormalizedResult{}, grpcerr.WithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_AI_INPUT_INVALID) + } + // The adapter owns protocol shape, while the exact composed model remains + // transport-owned and cannot be supplied in the App's text specification. + body["model"], _ = json.Marshal(resolvedModelID) + payload, err := json.Marshal(body) + if err != nil { + return textbehavior.NormalizedResult{}, MapProviderRequestError(err) + } + request, err := backend.newRequest(ctx, http.MethodPost, backend.baseURL+path, bytes.NewReader(payload)) + if err != nil { + return textbehavior.NormalizedResult{}, err + } + request.Header.Set("Content-Type", serialized.ContentType) + if wireStream { + request.Header.Set("Accept", "text/event-stream") + } + response, err := backend.do(request) + if err != nil { + return textbehavior.NormalizedResult{}, MapProviderRequestError(err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + var providerError map[string]any + _ = json.NewDecoder(response.Body).Decode(&providerError) + return textbehavior.NormalizedResult{}, MapProviderHTTPError(response.StatusCode, providerError) + } + if !wireStream { + body, err := io.ReadAll(response.Body) + if err != nil { + return textbehavior.NormalizedResult{}, MapProviderRequestError(err) + } + return invocation.ParseNonStream(body) + } + assembler, err := invocation.NewStreamAssembler() + if err != nil { + return textbehavior.NormalizedResult{}, err + } + scanner := bufio.NewScanner(response.Body) + scanner.Buffer(make([]byte, 0, 64*1024), anthropicMessageStreamLimit) + var data []string + consume := func() error { + if len(data) == 0 { + return nil + } + fragment := strings.Join(data, "\n") + data = nil + deltas, err := assembler.Append([]byte(fragment)) + if err != nil { + return err + } + for _, delta := range deltas { + if onDelta != nil && delta.HasPublicPayload() { + if err := onDelta(delta); err != nil { + return err + } + } + } + return nil + } + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\r") + if line == "" { + if err := consume(); err != nil { + return textbehavior.NormalizedResult{}, err + } + } else if strings.HasPrefix(line, "data:") { + data = append(data, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) + } + } + if err := scanner.Err(); err != nil { + return textbehavior.NormalizedResult{}, MapProviderRequestError(err) + } + if err := consume(); err != nil { + return textbehavior.NormalizedResult{}, err + } + if err := ctx.Err(); err != nil { + return textbehavior.NormalizedResult{}, err + } + return assembler.Finish() +} diff --git a/runtime/internal/nimillm/text_behavior_admission.go b/runtime/internal/nimillm/text_behavior_admission.go deleted file mode 100644 index 0348910ee4..0000000000 --- a/runtime/internal/nimillm/text_behavior_admission.go +++ /dev/null @@ -1,91 +0,0 @@ -package nimillm - -import ( - "context" - "strings" - - runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" -) - -type textBehaviorAdmissionKey struct{} - -// TextBehaviorAdmission is a request-scoped proof created only after Runtime -// resolves one exact private versioned adapter. It is never caller-authored. -type TextBehaviorAdmission struct { - AdapterID string - Version string - Provider string - ProviderModelID string - ToolUse bool - Reasoning bool - StructuredOutput bool - Sync bool - Stream bool - Async bool - ToolStructuredCombination bool -} - -func WithTextBehaviorAdmission(ctx context.Context, admission *TextBehaviorAdmission) context.Context { - if ctx == nil { - ctx = context.Background() - } - if !validTextBehaviorAdmission(admission) { - return ctx - } - copy := *admission - return context.WithValue(ctx, textBehaviorAdmissionKey{}, ©) -} - -func textBehaviorAdmissionFromContext(ctx context.Context) *TextBehaviorAdmission { - if ctx == nil { - return nil - } - admission, _ := ctx.Value(textBehaviorAdmissionKey{}).(*TextBehaviorAdmission) - if !validTextBehaviorAdmission(admission) { - return nil - } - copy := *admission - return © -} - -func validTextBehaviorAdmission(admission *TextBehaviorAdmission) bool { - if admission == nil || strings.TrimSpace(admission.AdapterID) == "" || admission.AdapterID != strings.TrimSpace(admission.AdapterID) || - strings.TrimSpace(admission.Version) == "" || admission.Version != strings.TrimSpace(admission.Version) || - strings.TrimSpace(admission.Provider) == "" || admission.Provider != strings.TrimSpace(admission.Provider) || - strings.TrimSpace(admission.ProviderModelID) == "" || admission.ProviderModelID != strings.TrimSpace(admission.ProviderModelID) || - (!admission.ToolUse && !admission.Reasoning && !admission.StructuredOutput) || - trueCount(admission.Sync, admission.Stream, admission.Async) != 1 { - return false - } - return !admission.ToolStructuredCombination || (admission.ToolUse && admission.StructuredOutput) -} - -func trueCount(values ...bool) int { - count := 0 - for _, value := range values { - if value { - count++ - } - } - return count -} - -func textMessagesRequestBehavior(input []*runtimev1.ChatMessage) (toolUse bool, reasoning bool) { - for _, message := range input { - if message == nil { - continue - } - for _, item := range message.GetTurnItems() { - if item == nil { - continue - } - if item.GetToolResult() != nil || item.GetOutput().GetToolCall() != nil { - toolUse = true - } - if item.GetOutput().GetReasoningSummary() != nil || item.GetOutput().GetReasoningContinuity() != nil { - reasoning = true - } - } - } - return toolUse, reasoning -} diff --git a/runtime/internal/remoteexecution/text_host.go b/runtime/internal/remoteexecution/text_host.go index de8cecff2d..172c42ab16 100644 --- a/runtime/internal/remoteexecution/text_host.go +++ b/runtime/internal/remoteexecution/text_host.go @@ -15,6 +15,7 @@ import ( "github.com/nimiplatform/nimi/runtime/internal/grpcerr" "github.com/nimiplatform/nimi/runtime/internal/nimillm" "github.com/nimiplatform/nimi/runtime/internal/services/connector" + "github.com/nimiplatform/nimi/runtime/internal/textbehavior" "github.com/oklog/ulid/v2" "google.golang.org/grpc/codes" "google.golang.org/protobuf/types/known/structpb" @@ -44,7 +45,7 @@ type TextDispatchAudit struct { // transports one exact request. type TextHost interface { ExecuteText(context.Context, connector.ConnectorRecord, capabilitydriver.CloudTextTarget, *capabilitydriver.CloudTextMappedRequest, TextDispatchAudit) (capabilitydriver.CloudTextTransportResponse, error) - StreamText(context.Context, connector.ConnectorRecord, capabilitydriver.CloudTextTarget, *capabilitydriver.CloudTextMappedRequest, func(string) error, TextDispatchAudit) (capabilitydriver.CloudTextTransportResponse, error) + StreamText(context.Context, connector.ConnectorRecord, capabilitydriver.CloudTextTarget, *capabilitydriver.CloudTextMappedRequest, func(textbehavior.OrderedDelta) error, TextDispatchAudit) (capabilitydriver.CloudTextTransportResponse, error) } type auditSink interface { @@ -85,6 +86,16 @@ func (h *ProviderTextHost) ExecuteText( return capabilitydriver.CloudTextTransportResponse{}, h.auditedError(audit, "error", err) } spec := request.Spec() + if invocation, serialized := request.TextBehavior(); invocation != nil { + result, err := h.transport.ExecuteTextBehaviorWithTarget(ctx, request.ProviderModelID(), remoteTarget, invocation, serialized, nil) + if err != nil { + return capabilitydriver.CloudTextTransportResponse{}, h.auditedError(audit, dispatchExit(ctx, "error"), err) + } + if err := h.recordDispatch(audit, "complete", runtimev1.ReasonCode_ACTION_EXECUTED, false); err != nil { + return capabilitydriver.CloudTextTransportResponse{}, err + } + return capabilitydriver.CloudTextTransportResponse{Items: result.Items, Usage: result.Usage, FinishReason: result.FinishReason}, nil + } text, toolCalls, usage, finish, err := h.transport.GenerateTextScenarioWithTarget( ctx, request.ProviderModelID(), @@ -100,7 +111,7 @@ func (h *ProviderTextHost) ExecuteText( return capabilitydriver.CloudTextTransportResponse{}, err } return capabilitydriver.CloudTextTransportResponse{ - Text: text, ToolCalls: toolCalls, Usage: usage, FinishReason: finish, + Items: primitiveCloudTextItems(text, toolCalls), Usage: usage, FinishReason: finish, }, nil } @@ -109,7 +120,7 @@ func (h *ProviderTextHost) StreamText( connectorRecord connector.ConnectorRecord, target capabilitydriver.CloudTextTarget, request *capabilitydriver.CloudTextMappedRequest, - onDelta func(string) error, + onDelta func(textbehavior.OrderedDelta) error, audit TextDispatchAudit, ) (capabilitydriver.CloudTextTransportResponse, error) { if err := h.recordDispatch(audit, "dispatch", runtimev1.ReasonCode_ACTION_EXECUTED, false); err != nil { @@ -124,21 +135,51 @@ func (h *ProviderTextHost) StreamText( err = grpcerr.WithReasonCode(codes.FailedPrecondition, runtimev1.ReasonCode_AI_CONFIG_INVALID) return capabilitydriver.CloudTextTransportResponse{}, h.auditedError(audit, "error", err) } + if invocation, serialized := request.TextBehavior(); invocation != nil { + result, err := h.transport.ExecuteTextBehaviorWithTarget(ctx, request.ProviderModelID(), remoteTarget, invocation, serialized, onDelta) + if err != nil { + return capabilitydriver.CloudTextTransportResponse{}, h.auditedError(audit, dispatchExit(ctx, "error"), err) + } + if err := h.recordDispatch(audit, "complete", runtimev1.ReasonCode_ACTION_EXECUTED, false); err != nil { + return capabilitydriver.CloudTextTransportResponse{}, err + } + return capabilitydriver.CloudTextTransportResponse{Items: result.Items, Usage: result.Usage, FinishReason: result.FinishReason}, nil + } + var text strings.Builder usage, finish, err := h.transport.StreamGenerateTextScenarioWithTarget( ctx, request.ProviderModelID(), request.Spec(), - onDelta, + func(part string) error { + text.WriteString(part) + return onDelta(textbehavior.OrderedDelta{Kind: textbehavior.OrderedItemText, Text: part}) + }, remoteTarget, request.WireDirectives(), ) if err != nil { return capabilitydriver.CloudTextTransportResponse{}, h.auditedError(audit, dispatchExit(ctx, "error"), err) } + if text.Len() > 0 { + if err := onDelta(textbehavior.OrderedDelta{Kind: textbehavior.OrderedItemText, ItemCompleted: true}); err != nil { + return capabilitydriver.CloudTextTransportResponse{}, err + } + } if err := h.recordDispatch(audit, "complete", runtimev1.ReasonCode_ACTION_EXECUTED, false); err != nil { return capabilitydriver.CloudTextTransportResponse{}, err } - return capabilitydriver.CloudTextTransportResponse{Usage: usage, FinishReason: finish, Streamed: true}, nil + return capabilitydriver.CloudTextTransportResponse{Items: primitiveCloudTextItems(text.String(), nil), Usage: usage, FinishReason: finish}, nil +} + +func primitiveCloudTextItems(text string, calls []*runtimev1.ToolCall) []textbehavior.OrderedItem { + items := make([]textbehavior.OrderedItem, 0, len(calls)+1) + if text != "" { + items = append(items, textbehavior.OrderedItem{Kind: textbehavior.OrderedItemText, Text: text}) + } + for _, call := range calls { + items = append(items, textbehavior.OrderedItem{Kind: textbehavior.OrderedItemToolCall, ToolCall: call}) + } + return items } // requestScopedTarget is the only credential opening point. The Connector diff --git a/runtime/internal/services/ai/anthropic_app_text_test.go b/runtime/internal/services/ai/anthropic_app_text_test.go new file mode 100644 index 0000000000..e6e61b2991 --- /dev/null +++ b/runtime/internal/services/ai/anthropic_app_text_test.go @@ -0,0 +1,228 @@ +package ai + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "github.com/nimiplatform/nimi/runtime/internal/grpcerr" + "github.com/nimiplatform/nimi/runtime/internal/localappop" + accountservice "github.com/nimiplatform/nimi/runtime/internal/services/account" + "google.golang.org/protobuf/types/known/structpb" +) + +func anthropicAppFixture(t *testing.T, handler http.HandlerFunc) (managedCloudScenarioTestFixture, func(accountservice.LocalAppOperation, string) context.Context) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + fixture := newManagedCloudScenarioTestFixture(t, "anthropic", "claude-sonnet-4-6", server.URL, Config{AllowLoopbackEndpoint: true}) + target, _ := structpb.NewStruct(map[string]any{"provider": "anthropic", "providerModelId": "claude-sonnet-4-6", "remoteModelCatalogId": fixture.descriptor.GetRemoteModelCatalogId()}) + config := appAIConfig("app.anthropic", &runtimev1.AIConfigCapabilityIntent{ + CapabilityContract: "text.generate", Route: &runtimev1.AIConfigCapabilityIntent_Cloud{Cloud: &runtimev1.AIConfigCloudIntent{ + Implementation: &runtimev1.CapabilityImplementationIdentity{ImplementationId: "anthropic", DriverId: "nimillm", DriverDialect: "anthropic"}, + ConnectorRef: fixture.connectorID, ProviderModelTarget: target, + }}, + }) + if err := overwriteAIConfigStoreForTest(context.Background(), fixture.service.aiConfigStore, "user-001", config); err != nil { + t.Fatal(err) + } + return fixture, func(operation accountservice.LocalAppOperation, capability string) context.Context { + return accountservice.ContextWithAuthorizedLocalAppDecision(context.Background(), accountservice.LocalAppCallerDecision{AccountID: "user-001", AppID: "app.anthropic", RegisteredAppSubject: "app-subject-anthropic", Operation: operation, AuthorityClass: localappop.AuthorityClassAppAccess, OperationCapability: capability}) + } +} + +func TestAnthropicAppTextRoundTripUsesProductionHooksAndOrderedResults(t *testing.T) { + var calls atomic.Int32 + fixture, decision := anthropicAppFixture(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.URL.Path != "/v1/messages" || r.Header.Get("x-api-key") != "test-key" || r.Header.Get("anthropic-version") == "" { + t.Errorf("incorrect protocol or credential projection") + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + if body["model"] != "claude-sonnet-4-6" { + t.Errorf("model = %v", body["model"]) + } + w.Header().Set("Content-Type", "application/json") + if calls.Load() == 1 { + fmt.Fprint(w, `{"content":[{"type":"text","text":"Checking "},{"type":"tool_use","id":"call-1","name":"lookup","input":{"query":"one"}},{"type":"text","text":" then "},{"type":"tool_use","id":"call-2","name":"lookup","input":{"query":"two"}}],"stop_reason":"tool_use","usage":{"input_tokens":4,"output_tokens":8}}`) + return + } + messages := body["messages"].([]any) + last := messages[len(messages)-1].(map[string]any) + results := last["content"].([]any) + if last["role"] != "user" || len(results) != 2 || results[0].(map[string]any)["tool_use_id"] != "call-2" || results[1].(map[string]any)["tool_use_id"] != "call-1" { + t.Errorf("tool results reordered or mispaired: %v", results) + } + fmt.Fprint(w, `{"content":[{"type":"text","text":"Both lookups completed."}],"stop_reason":"end_turn","usage":{"input_tokens":8,"output_tokens":5}}`) + }) + input := &runtimev1.StreamLocalAppTextTurnRequest{Messages: []*runtimev1.LocalAppTextCandidateMessage{{Role: "user", Text: "Use both lookups"}}, Tools: []*runtimev1.ToolSpec{localAppLookupTool(t)}, ToolChoice: runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_AUTO} + execute := func() (*runtimev1.ExecuteLocalAppScenarioResponse, error) { + return fixture.service.ExecuteLocalAppScenario(decision(accountservice.LocalAppOperationScenarioExecute, localappop.AppOperationIDScenarioExecute), &runtimev1.ExecuteLocalAppScenarioRequest{Spec: &runtimev1.ExecuteLocalAppScenarioRequest_TextGenerate{TextGenerate: input}}) + } + first, err := execute() + if err != nil { + t.Fatal(err) + } + items := first.GetTextGenerate().GetItems() + if len(items) != 4 || items[2].GetText().GetText() != " then " || items[3].GetToolCall().GetId() != "call-2" { + t.Fatalf("ordered output = %v", items) + } + turn := &runtimev1.LocalAppTextCandidateMessage{Role: "assistant"} + for _, item := range items { + turn.TurnItems = append(turn.TurnItems, &runtimev1.TextTurnItem{Item: &runtimev1.TextTurnItem_Output{Output: item}}) + } + for _, index := range []int{3, 1} { + call := items[index].GetToolCall() + var arguments map[string]string + if err := json.Unmarshal([]byte(call.GetArgumentsJson()), &arguments); err != nil { + t.Fatal(err) + } + // The consumer executes this deterministic handler; Runtime never does. + value := structpb.NewStringValue(strings.ToUpper(arguments["query"])) + turn.TurnItems = append(turn.TurnItems, &runtimev1.TextTurnItem{Item: &runtimev1.TextTurnItem_ToolResult{ToolResult: &runtimev1.ToolResult{ToolCallId: call.GetId(), ToolName: call.GetName(), Result: value}}}) + } + input.Messages = append(input.Messages, turn) + input.ToolChoice = runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_NONE + last, err := execute() + if err != nil || last.GetTextGenerate().GetItems()[0].GetText().GetText() != "Both lookups completed." || calls.Load() != 2 { + t.Fatalf("round trip result = %v %v calls=%d", last, err, calls.Load()) + } +} + +func TestAnthropicAppStreamPublishesCompleteToolsAndRejectsMissingTerminal(t *testing.T) { + var omitStop atomic.Bool + fixture, decision := anthropicAppFixture(t, func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + if body["stream"] != true || body["tool_choice"].(map[string]any)["type"] != "any" { + t.Errorf("stream tool choice was lost: %v", body) + } + w.Header().Set("Content-Type", "text/event-stream") + for _, event := range []string{ + `{"type":"message_start","message":{"content":[],"usage":{"input_tokens":4,"output_tokens":1}}}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call-1","name":"lookup","input":{}}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"one\"}"}}`, + `{"type":"content_block_stop","index":0}`, + `{"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":8}}`, + } { + fmt.Fprintf(w, "data: %s\n\n", event) + } + if !omitStop.Load() { + fmt.Fprint(w, "data: {\"type\":\"message_stop\"}\n\n") + } + }) + input := &runtimev1.StreamLocalAppTextTurnRequest{Messages: []*runtimev1.LocalAppTextCandidateMessage{{Role: "user", Text: "Lookup"}}, Tools: []*runtimev1.ToolSpec{localAppLookupTool(t)}, ToolChoice: runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_REQUIRED} + for _, missing := range []bool{false, true} { + omitStop.Store(missing) + stream := &mockLocalAppTextTurnStream{ctx: decision(accountservice.LocalAppOperationTextTurnStream, localappop.AppOperationIDTextTurnStream)} + err := fixture.service.StreamLocalAppTextTurn(input, stream) + completed, failed, ready := 0, 0, 0 + for _, event := range stream.events { + if event.GetCompleted() != nil { + completed++ + } + if event.GetFailed() != nil { + failed++ + } + if event.GetToolCall() != nil { + ready++ + } + } + if !missing && (err != nil || completed != 1 || ready != 1) { + t.Fatalf("stream did not complete: %v %+v", err, stream.events) + } + if missing && (completed != 0 || failed != 1 && err == nil) { + t.Fatalf("unterminated stream succeeded: %v %+v", err, stream.events) + } + } +} + +func TestAnthropicAppStructuredOutputUsesTheNativeSchemaAndRejectsUnsupportedConstraints(t *testing.T) { + var calls atomic.Int32 + fixture, decision := anthropicAppFixture(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + format, ok := body["output_config"].(map[string]any) + if !ok || format["format"].(map[string]any)["type"] != "json_schema" { + t.Errorf("native schema was omitted: %v", body) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"content":[{"type":"text","text":"{\"query\":\"ok\"}"}],"stop_reason":"end_turn","usage":{"input_tokens":2,"output_tokens":3}}`) + }) + input := &runtimev1.StreamLocalAppTextTurnRequest{ + Messages: []*runtimev1.LocalAppTextCandidateMessage{{Role: "user", Text: "Return structured data"}}, + ResponseFormat: &runtimev1.ResponseFormat{Kind: runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_SCHEMA, JsonSchema: localAppLookupTool(t).InputSchema, Strict: true}, + } + execute := func() (*runtimev1.ExecuteLocalAppScenarioResponse, error) { + return fixture.service.ExecuteLocalAppScenario(decision(accountservice.LocalAppOperationScenarioExecute, localappop.AppOperationIDScenarioExecute), &runtimev1.ExecuteLocalAppScenarioRequest{Spec: &runtimev1.ExecuteLocalAppScenarioRequest_TextGenerate{TextGenerate: input}}) + } + result, err := execute() + if err != nil || result.GetTextGenerate().GetItems()[0].GetText().GetText() != `{"query":"ok"}` { + t.Fatalf("structured result = %v %v", result, err) + } + input.ResponseFormat.JsonSchema.Fields["minProperties"] = structpb.NewNumberValue(1) + _, err = execute() + if reason, _ := grpcerr.ExtractReasonCode(err); reason != runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED || calls.Load() != 1 { + t.Fatalf("unsupported schema reached provider: %v calls=%d", err, calls.Load()) + } +} + +func TestAnthropicAppCancellationClosesProviderTransportWithoutCompletingTools(t *testing.T) { + started, closed := make(chan struct{}), make(chan struct{}) + fixture, decision := anthropicAppFixture(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: {\"type\":\"message_start\",\"message\":{\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n") + w.(http.Flusher).Flush() + close(started) + <-r.Context().Done() + close(closed) + }) + ctx, cancel := context.WithCancel(decision(accountservice.LocalAppOperationTextTurnStream, localappop.AppOperationIDTextTurnStream)) + defer cancel() + stream := &mockLocalAppTextTurnStream{ctx: ctx} + finished := make(chan error, 1) + go func() { + finished <- fixture.service.StreamLocalAppTextTurn(&runtimev1.StreamLocalAppTextTurnRequest{ + Messages: []*runtimev1.LocalAppTextCandidateMessage{{Role: "user", Text: "Lookup"}}, Tools: []*runtimev1.ToolSpec{localAppLookupTool(t)}, ToolChoice: runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_REQUIRED, + }, stream) + }() + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("provider was not reached") + } + cancel() + select { + case <-finished: + case <-time.After(5 * time.Second): + t.Fatal("canceled consumer kept waiting") + } + select { + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("provider transport remained open") + } + for _, event := range stream.events { + if event.GetCompleted() != nil || event.GetToolCall() != nil { + t.Fatalf("cancellation published success or an incomplete call: %v", event) + } + } +} diff --git a/runtime/internal/services/ai/cloud_embed_execution_test.go b/runtime/internal/services/ai/cloud_embed_execution_test.go index 2d582042d0..f2cd203ed7 100644 --- a/runtime/internal/services/ai/cloud_embed_execution_test.go +++ b/runtime/internal/services/ai/cloud_embed_execution_test.go @@ -10,6 +10,7 @@ import ( "testing" runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + aicatalog "github.com/nimiplatform/nimi/runtime/internal/aicatalog" "github.com/nimiplatform/nimi/runtime/internal/capabilitydriver" "github.com/nimiplatform/nimi/runtime/internal/grpcerr" "github.com/nimiplatform/nimi/runtime/internal/localexecution" @@ -53,6 +54,12 @@ func TestCloudEmbedExecutionUsesCapturedAIConfigConnectorWithoutFallback(t *test CloudProviders: map[string]nimillm.ProviderCredentials{}, AllowLoopbackEndpoint: true, }) + catalog, err := aicatalog.NewResolver(aicatalog.ResolverConfig{CustomDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + fixture.service.speechCatalog = catalog + fixture.connectorService.SetModelCatalogResolver(catalog) target, err := structpb.NewStruct(map[string]any{ "provider": "openai", "providerModelId": fixture.descriptor.GetProviderModelId(), @@ -90,6 +97,9 @@ func TestCloudEmbedExecutionUsesCapturedAIConfigConnectorWithoutFallback(t *test t.Fatalf("ExecuteScenario(text.embed): %v", err) } vectors := response.GetOutput().GetTextEmbed().GetVectors() + if response.GetOutput().GetTextEmbed().GetSpaceId() == "" { + t.Fatal("embedding response omitted its vector space") + } if len(vectors) != 2 || len(vectors[0].GetValues()) != 2 || vectors[1].GetValues()[1] != 0.4 { t.Fatalf("embedding vectors = %+v", vectors) } @@ -99,6 +109,54 @@ func TestCloudEmbedExecutionUsesCapturedAIConfigConnectorWithoutFallback(t *test if response.GetUsage().GetInputTokens() != 3 { t.Fatalf("embedding usage = %+v", response.GetUsage()) } + spaceID := response.GetOutput().GetTextEmbed().GetSpaceId() + + // Admission tracks the whole inventory, but a chat-only catalog change + // must not change the embedding space after the same target is recommitted. + _, err = fixture.connectorService.UpsertModelCatalogProvider(fixture.context, &runtimev1.UpsertModelCatalogProviderRequest{ + Provider: "openai", + Yaml: `version: 1 +provider: openai +catalog_version: chat-only-update +models: + - provider: openai + model_id: additional-chat-model + model_type: chat + updated_at: "2026-09-13" + capabilities: [text.generate] + pricing: + unit: token + input: "unknown" + output: "unknown" + currency: USD + as_of: "2026-09-13" + notes: chat catalog fixture + source_ref: + url: https://example.com/chat-model + retrieved_at: "2026-09-13" + note: chat catalog fixture +voices: [] +`, + }) + if err != nil { + t.Fatalf("update chat catalog: %v", err) + } + _, err = fixture.service.ExecuteScenario(ctx, request) + if reason, ok := grpcerr.ExtractReasonCode(err); !ok || reason != runtimev1.ReasonCode_AI_REMOTE_MODEL_CATALOG_STALE || calls.Load() != 1 { + t.Fatalf("stale catalog must fail before dispatch: calls=%d err=%v", calls.Load(), err) + } + descriptor := connectorModelDescriptorForAITest(t, fixture.connectorService, fixture.context, fixture.connectorID, "text-embedding-3-small") + target.Fields["remoteModelCatalogId"] = structpb.NewStringValue(descriptor.GetRemoteModelCatalogId()) + if err := overwriteAIConfigStoreForTest(ctx, fixture.service.aiConfigStore, "user-001", config); err != nil { + t.Fatalf("recommit embedding target: %v", err) + } + response, err = fixture.service.ExecuteScenario(ctx, request) + if err != nil { + t.Fatalf("embed after catalog refresh: %v", err) + } + if got := response.GetOutput().GetTextEmbed().GetSpaceId(); got != spaceID { + t.Fatalf("chat-only catalog change invalidated embedding space: before=%q after=%q", spaceID, got) + } failAuth.Store(true) _, err = fixture.service.ExecuteScenario(ctx, request) @@ -106,8 +164,8 @@ func TestCloudEmbedExecutionUsesCapturedAIConfigConnectorWithoutFallback(t *test t.Fatalf("embedding auth reason = %v present=%v err=%v", reason, ok, err) } failAuth.Store(false) - if calls.Load() != 2 { - t.Fatalf("provider calls = %d, want exactly two and no fallback dispatch", calls.Load()) + if calls.Load() != 3 { + t.Fatalf("provider calls = %d, want exactly three and no fallback dispatch", calls.Load()) } } diff --git a/runtime/internal/services/ai/cloud_text_execution.go b/runtime/internal/services/ai/cloud_text_execution.go index 5c768412b2..b31532fde3 100644 --- a/runtime/internal/services/ai/cloud_text_execution.go +++ b/runtime/internal/services/ai/cloud_text_execution.go @@ -15,6 +15,7 @@ import ( "github.com/nimiplatform/nimi/runtime/internal/nimillm" "github.com/nimiplatform/nimi/runtime/internal/remoteexecution" "github.com/nimiplatform/nimi/runtime/internal/services/connector" + "github.com/nimiplatform/nimi/runtime/internal/textbehavior" "github.com/oklog/ulid/v2" "google.golang.org/grpc/codes" "google.golang.org/protobuf/encoding/protojson" @@ -158,6 +159,10 @@ func (s *Service) captureCloudTextEffectiveInputs( if err != nil { return fail(cloudTextDriverError(err)) } + mapped, err = bindCloudTextBehavior(mapped, behaviorAdapter) + if err != nil { + return fail(err) + } implementation, _ := proto.Clone(intent.CloudImplementation).(*runtimev1.CapabilityImplementationIdentity) rawTarget, _ := proto.Clone(intent.ProviderModelTarget).(*structpb.Struct) defaults, _ := proto.Clone(intent.Defaults).(*structpb.Struct) @@ -244,6 +249,10 @@ func (s *Service) cloudTextEffectiveInputsFromResolvedAssembly(assembly *cloudRe if err != nil { return nil, cloudTextDriverError(err) } + mapped, err = bindCloudTextBehavior(mapped, behaviorAdapter) + if err != nil { + return nil, err + } clonedAssembly, err := cloneCloudResolvedAssembly(assembly) if err != nil { return nil, grpcerr.WrapWithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID, err, grpcerr.ReasonOptions{}) @@ -286,7 +295,6 @@ func (s *Service) executeCapturedCloudText(ctx context.Context, effective *cloud if s == nil || effective == nil || effective.driver == nil || s.remoteTextHost == nil { return capabilitydriver.CloudTextResult{}, grpcerr.WithReasonCode(codes.Unavailable, runtimev1.ReasonCode_AI_PROVIDER_UNAVAILABLE) } - ctx = nimillm.WithTextBehaviorAdmission(ctx, effective.behaviorAdapter.nimillmAdmission()) transportResponse, err := s.remoteTextHost.ExecuteText(ctx, effective.connector, effective.target, effective.mapped, effective.dispatchAudit()) if err != nil { return capabilitydriver.CloudTextResult{}, effective.driver.NormalizeReason(err) @@ -298,18 +306,17 @@ func (s *Service) executeCapturedCloudText(ctx context.Context, effective *cloud return result, nil } -func (s *Service) streamCapturedCloudText(ctx context.Context, effective *cloudTextEffectiveInputs, onDelta func(string) error) (capabilitydriver.CloudTextResult, error) { +func (s *Service) streamCapturedCloudText(ctx context.Context, effective *cloudTextEffectiveInputs, onDelta func(textbehavior.OrderedDelta) error) (capabilitydriver.CloudTextResult, error) { if s == nil || effective == nil || effective.driver == nil || s.remoteTextHost == nil || onDelta == nil { return capabilitydriver.CloudTextResult{}, grpcerr.WithReasonCode(codes.Unavailable, runtimev1.ReasonCode_AI_PROVIDER_UNAVAILABLE) } - ctx = nimillm.WithTextBehaviorAdmission(ctx, effective.behaviorAdapter.nimillmAdmission()) - transportResponse, err := s.remoteTextHost.StreamText(ctx, effective.connector, effective.target, effective.mapped, func(raw string) error { - delta, normalizeErr := effective.driver.NormalizeStreamDelta(raw) - if normalizeErr != nil { - return cloudTextDriverError(normalizeErr) - } - if delta == "" { - return nil + transportResponse, err := s.remoteTextHost.StreamText(ctx, effective.connector, effective.target, effective.mapped, func(delta textbehavior.OrderedDelta) error { + if delta.Kind == textbehavior.OrderedItemText { + text, err := effective.driver.NormalizeStreamDelta(delta.Text) + if err != nil { + return cloudTextDriverError(err) + } + delta.Text = text } return onDelta(delta) }, effective.dispatchAudit()) @@ -323,6 +330,14 @@ func (s *Service) streamCapturedCloudText(ctx context.Context, effective *cloudT return result, nil } +func bindCloudTextBehavior(mapped *capabilitydriver.CloudTextMappedRequest, adapter *resolvedTextBehaviorAdapter) (*capabilitydriver.CloudTextMappedRequest, error) { + resolved, err := adapter.runtimeAdapter() + if err != nil { + return nil, err + } + return mapped.WithTextBehavior(resolved) +} + func (s *Service) auditCloudTextCapture(effective *cloudTextEffectiveInputs, stream bool) error { if s == nil || s.audit == nil || effective == nil { return nil diff --git a/runtime/internal/services/ai/cloud_text_raw_chunks_test.go b/runtime/internal/services/ai/cloud_text_raw_chunks_test.go new file mode 100644 index 0000000000..bd1113acfc --- /dev/null +++ b/runtime/internal/services/ai/cloud_text_raw_chunks_test.go @@ -0,0 +1,56 @@ +package ai + +import ( + "net/http" + "sync/atomic" + "testing" + + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "github.com/nimiplatform/nimi/runtime/internal/grpcerr" +) + +func TestCloudTextBehaviorRejectsRawChunksBeforeDispatch(t *testing.T) { + for _, provider := range []string{"anthropic", "codex"} { + t.Run(provider, func(t *testing.T) { + var calls atomic.Int32 + handler := func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + http.Error(w, "unexpected raw chunk request", http.StatusBadRequest) + } + var fixture managedCloudScenarioTestFixture + if provider == "anthropic" { + fixture, _ = anthropicAppFixture(t, handler) + } else { + fixture, _ = codexAppFixture(t, "gpt-5.6-sol", handler) + } + app := "app." + provider + ctx := scenarioJobUserContext(app, "user-001") + head := &runtimev1.ScenarioRequestHead{AppId: app, SubjectUserId: "user-001", TimeoutMs: 10_000} + spec := &runtimev1.ScenarioSpec{Spec: &runtimev1.ScenarioSpec_TextGenerate{TextGenerate: &runtimev1.TextGenerateScenarioSpec{ + Input: []*runtimev1.ChatMessage{{Role: "user", Content: "respond"}}, + Tools: []*runtimev1.ToolSpec{localAppLookupTool(t)}, + ToolChoice: runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_NONE, + IncludeRawChunks: true, + }}} + for _, mode := range []string{"sync", "stream"} { + t.Run(mode, func(t *testing.T) { + var err error + if mode == "sync" { + _, err = fixture.service.ExecuteScenario(ctx, &runtimev1.ExecuteScenarioRequest{ + Head: head, ScenarioType: runtimev1.ScenarioType_SCENARIO_TYPE_TEXT_GENERATE, + ExecutionMode: runtimev1.ExecutionMode_EXECUTION_MODE_SYNC, Spec: spec, + }) + } else { + err = fixture.service.StreamScenario(&runtimev1.StreamScenarioRequest{ + Head: head, ScenarioType: runtimev1.ScenarioType_SCENARIO_TYPE_TEXT_GENERATE, Spec: spec, + }, &mockScenarioEventStream{ctx: ctx}) + } + reason, _ := grpcerr.ExtractReasonCode(err) + if reason != runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED || calls.Load() != 0 { + t.Fatalf("raw chunk request = %v, provider calls = %d", err, calls.Load()) + } + }) + } + }) + } +} diff --git a/runtime/internal/services/ai/codex_app_text_test.go b/runtime/internal/services/ai/codex_app_text_test.go new file mode 100644 index 0000000000..f9481394ab --- /dev/null +++ b/runtime/internal/services/ai/codex_app_text_test.go @@ -0,0 +1,406 @@ +package ai + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "github.com/nimiplatform/nimi/runtime/internal/grpcerr" + "github.com/nimiplatform/nimi/runtime/internal/localappop" + accountservice "github.com/nimiplatform/nimi/runtime/internal/services/account" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" +) + +func codexAppFixture(t *testing.T, modelID string, handler http.HandlerFunc) (managedCloudScenarioTestFixture, func(accountservice.LocalAppOperation, string) context.Context) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + fixture := newManagedCloudScenarioTestFixture(t, "openai_codex", modelID, server.URL, Config{AllowLoopbackEndpoint: true}) + target, _ := structpb.NewStruct(map[string]any{"provider": "openai_codex", "providerModelId": modelID, "remoteModelCatalogId": fixture.descriptor.GetRemoteModelCatalogId()}) + config := appAIConfig("app.codex", &runtimev1.AIConfigCapabilityIntent{CapabilityContract: "text.generate", Route: &runtimev1.AIConfigCapabilityIntent_Cloud{Cloud: &runtimev1.AIConfigCloudIntent{Implementation: &runtimev1.CapabilityImplementationIdentity{ImplementationId: "openai_codex", DriverId: "nimillm", DriverDialect: "openai_codex"}, ConnectorRef: fixture.connectorID, ProviderModelTarget: target}}}) + if err := overwriteAIConfigStoreForTest(context.Background(), fixture.service.aiConfigStore, "user-001", config); err != nil { + t.Fatal(err) + } + return fixture, func(operation accountservice.LocalAppOperation, capability string) context.Context { + return accountservice.ContextWithAuthorizedLocalAppDecision(context.Background(), accountservice.LocalAppCallerDecision{AccountID: "user-001", AppID: "app.codex", RegisteredAppSubject: "app-subject-codex", Operation: operation, AuthorityClass: localappop.AuthorityClassAppAccess, OperationCapability: capability}) + } +} + +func writeCodexAppResponse(t *testing.T, w http.ResponseWriter, output []map[string]any, completed bool) { + t.Helper() + w.Header().Set("Content-Type", "text/event-stream") + write := func(value any) { + bytes, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + fmt.Fprintf(w, "data: %s\n\n", bytes) + } + write(map[string]any{"type": "response.created", "response": map[string]any{"status": "in_progress"}}) + for index, item := range output { + start := map[string]any{"type": item["type"], "id": item["id"], "call_id": item["call_id"], "name": item["name"]} + write(map[string]any{"type": "response.output_item.added", "output_index": index, "item": start}) + write(map[string]any{"type": "response.output_item.done", "output_index": index, "item": item}) + } + if completed { + write(map[string]any{"type": "response.completed", "response": map[string]any{"status": "completed", "output": output, "usage": map[string]int{"input_tokens": 3, "output_tokens": 4}}}) + } + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } +} + +func TestCodexAppTextSSESyncAndStreamPreserveContinuityRoundTrip(t *testing.T) { + for _, modelID := range []string{"gpt-5.6-sol", "gpt-6-astra"} { + t.Run(modelID, func(t *testing.T) { + var requests atomic.Int32 + fixture, decision := codexAppFixture(t, modelID, func(w http.ResponseWriter, r *http.Request) { + number := requests.Add(1) + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + if r.URL.Path != "/responses" || r.Header.Get("Authorization") != "Bearer test-key" || body["stream"] != true || body["store"] != false || body["model"] != modelID { + t.Error("wrong captured Codex protocol") + } + if number == 1 { + writeCodexAppResponse(t, w, []map[string]any{ + {"type": "reasoning", "id": "rs_1", "encrypted_content": "opaque-test-value"}, + {"type": "function_call", "id": "fc_1", "call_id": "call_1", "name": "lookup", "arguments": `{"query":"one"}`}, + {"type": "function_call", "id": "fc_2", "call_id": "call_2", "name": "lookup", "arguments": `{"query":"two"}`}, + }, true) + return + } + input := body["input"].([]any) + if input[1].(map[string]any)["encrypted_content"] != "opaque-test-value" || input[4].(map[string]any)["call_id"] != "call_2" || input[5].(map[string]any)["call_id"] != "call_1" { + t.Error("continuity or result association lost") + } + writeCodexAppResponse(t, w, []map[string]any{{"type": "message", "id": "msg_1", "content": []any{map[string]any{"type": "output_text", "text": "Both lookups completed."}}}}, true) + }) + input := &runtimev1.StreamLocalAppTextTurnRequest{Messages: []*runtimev1.LocalAppTextCandidateMessage{{Role: "user", Text: "Use both lookups"}}, Tools: []*runtimev1.ToolSpec{localAppLookupTool(t)}, ToolChoice: runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_REQUIRED} + first, err := fixture.service.ExecuteLocalAppScenario(decision(accountservice.LocalAppOperationScenarioExecute, localappop.AppOperationIDScenarioExecute), &runtimev1.ExecuteLocalAppScenarioRequest{Spec: &runtimev1.ExecuteLocalAppScenarioRequest_TextGenerate{TextGenerate: input}}) + if err != nil { + t.Fatal(err) + } + items := first.GetTextGenerate().GetItems() + if len(items) != 3 || items[0].GetReasoningContinuity() == nil || items[1].GetToolCall().GetId() != "call_1" { + t.Fatalf("ordered outputs: %v", items) + } + turn := &runtimev1.LocalAppTextCandidateMessage{Role: "assistant"} + for _, item := range items { + turn.TurnItems = append(turn.TurnItems, &runtimev1.TextTurnItem{Item: &runtimev1.TextTurnItem_Output{Output: item}}) + } + for _, index := range []int{2, 1} { + call := items[index].GetToolCall() + turn.TurnItems = append(turn.TurnItems, &runtimev1.TextTurnItem{Item: &runtimev1.TextTurnItem_ToolResult{ToolResult: &runtimev1.ToolResult{ToolCallId: call.Id, ToolName: call.Name, Result: structpb.NewStringValue("complete")}}}) + } + input.Messages = append(input.Messages, turn) + input.ToolChoice = runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_NONE + stream := &mockLocalAppTextTurnStream{ctx: decision(accountservice.LocalAppOperationTextTurnStream, localappop.AppOperationIDTextTurnStream)} + if err := fixture.service.StreamLocalAppTextTurn(input, stream); err != nil { + t.Fatal(err) + } + if len(stream.events) != 2 || stream.events[0].GetDelta().GetText() != "Both lookups completed." || stream.events[1].GetCompleted() == nil || requests.Load() != 2 { + t.Fatalf("round trip stream=%v", stream.events) + } + }) + } +} + +func TestCodexAstraAppTextAndStructuredOutputUseSSE(t *testing.T) { + schema := localAppLookupTool(t).GetInputSchema() + for _, test := range []struct { + name string + format *runtimev1.ResponseFormat + text string + }{ + {name: "plain", text: "Astra completed the request."}, + { + name: "json schema", text: `{"query":"complete"}`, + format: &runtimev1.ResponseFormat{ + Kind: runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_SCHEMA, + JsonSchema: schema, SchemaName: "lookup_result", Strict: true, + }, + }, + } { + for _, mode := range []string{"sync", "stream"} { + t.Run(test.name+"/"+mode, func(t *testing.T) { + var requests atomic.Int32 + fixture, decision := codexAppFixture(t, "gpt-6-astra", func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + if r.URL.Path != "/responses" || body["model"] != "gpt-6-astra" || body["stream"] != true || body["store"] != false { + t.Errorf("wrong Astra protocol: %v", body) + } + if test.format != nil { + textConfig, _ := body["text"].(map[string]any) + format, _ := textConfig["format"].(map[string]any) + encodedSchema, _ := json.Marshal(format["schema"]) + requestedSchema, _ := json.Marshal(schema.AsMap()) + if format["type"] != "json_schema" || format["name"] != "lookup_result" || format["strict"] != true || string(encodedSchema) != string(requestedSchema) { + t.Errorf("native JSON Schema was not preserved: %v", format) + } + } + writeCodexAppResponse(t, w, []map[string]any{ + {"type": "reasoning", "id": "rs_astra", "encrypted_content": "astra-continuity"}, + {"type": "message", "id": "msg_astra", "content": []any{map[string]any{"type": "output_text", "text": test.text}}}, + }, true) + }) + input := &runtimev1.StreamLocalAppTextTurnRequest{ + Messages: []*runtimev1.LocalAppTextCandidateMessage{{Role: "user", Text: "Complete the request"}}, ResponseFormat: test.format, + } + if mode == "sync" { + response, err := fixture.service.ExecuteLocalAppScenario(decision(accountservice.LocalAppOperationScenarioExecute, localappop.AppOperationIDScenarioExecute), + &runtimev1.ExecuteLocalAppScenarioRequest{Spec: &runtimev1.ExecuteLocalAppScenarioRequest_TextGenerate{TextGenerate: input}}) + if err != nil { + t.Fatal(err) + } + output := response.GetTextGenerate() + if len(output.GetItems()) != 2 || output.GetItems()[0].GetReasoningContinuity() == nil || output.GetItems()[1].GetText().GetText() != test.text || output.GetFinishReason() != runtimev1.FinishReason_FINISH_REASON_STOP { + t.Fatalf("Astra output = %v", output) + } + } else { + stream := &mockLocalAppTextTurnStream{ctx: decision(accountservice.LocalAppOperationTextTurnStream, localappop.AppOperationIDTextTurnStream)} + if err := fixture.service.StreamLocalAppTextTurn(input, stream); err != nil { + t.Fatal(err) + } + if len(stream.events) != 3 || stream.events[0].GetReasoningContinuity() == nil || stream.events[1].GetDelta().GetText() != test.text || stream.events[2].GetCompleted().GetFinishReason() != runtimev1.FinishReason_FINISH_REASON_STOP { + t.Fatalf("Astra stream = %v", stream.events) + } + } + if requests.Load() != 1 { + t.Fatalf("provider requests = %d, want 1", requests.Load()) + } + }) + } + } +} + +func TestCodexAstraPlainTextRejectsUnsupportedParametersBeforeDispatch(t *testing.T) { + var calls atomic.Int32 + fixture, _ := codexAppFixture(t, "gpt-6-astra", func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + http.Error(w, "unexpected unsupported request", http.StatusBadRequest) + }) + for _, test := range []struct { + name string + apply func(*runtimev1.TextGenerateScenarioSpec) + }{ + {name: "temperature", apply: func(spec *runtimev1.TextGenerateScenarioSpec) { spec.Temperature = proto.Float32(0) }}, + {name: "top p", apply: func(spec *runtimev1.TextGenerateScenarioSpec) { spec.TopP = proto.Float32(1) }}, + {name: "top k", apply: func(spec *runtimev1.TextGenerateScenarioSpec) { spec.TopK = proto.Int32(3) }}, + {name: "max tokens", apply: func(spec *runtimev1.TextGenerateScenarioSpec) { spec.MaxTokens = proto.Int32(32) }}, + {name: "seed", apply: func(spec *runtimev1.TextGenerateScenarioSpec) { spec.Seed = proto.Int64(0) }}, + {name: "presence penalty", apply: func(spec *runtimev1.TextGenerateScenarioSpec) { spec.PresencePenalty = proto.Float32(0) }}, + {name: "frequency penalty", apply: func(spec *runtimev1.TextGenerateScenarioSpec) { spec.FrequencyPenalty = proto.Float32(0) }}, + {name: "stop", apply: func(spec *runtimev1.TextGenerateScenarioSpec) { spec.Stop = []string{"stop"} }}, + {name: "raw chunks", apply: func(spec *runtimev1.TextGenerateScenarioSpec) { spec.IncludeRawChunks = true }}, + } { + for _, mode := range []string{"sync", "stream"} { + t.Run(test.name+"/"+mode, func(t *testing.T) { + textSpec := &runtimev1.TextGenerateScenarioSpec{Input: []*runtimev1.ChatMessage{{Role: "user", Content: "respond"}}} + test.apply(textSpec) + spec := &runtimev1.ScenarioSpec{Spec: &runtimev1.ScenarioSpec_TextGenerate{TextGenerate: textSpec}} + head := &runtimev1.ScenarioRequestHead{AppId: "app.codex", SubjectUserId: "user-001", TimeoutMs: 10_000} + ctx := scenarioJobUserContext("app.codex", "user-001") + var err error + if mode == "sync" { + _, err = fixture.service.ExecuteScenario(ctx, &runtimev1.ExecuteScenarioRequest{ + Head: head, ScenarioType: runtimev1.ScenarioType_SCENARIO_TYPE_TEXT_GENERATE, + ExecutionMode: runtimev1.ExecutionMode_EXECUTION_MODE_SYNC, Spec: spec, + }) + } else { + err = fixture.service.StreamScenario(&runtimev1.StreamScenarioRequest{ + Head: head, ScenarioType: runtimev1.ScenarioType_SCENARIO_TYPE_TEXT_GENERATE, Spec: spec, + }, &mockScenarioEventStream{ctx: ctx}) + } + if reason, _ := grpcerr.ExtractReasonCode(err); reason != runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED || calls.Load() != 0 { + t.Fatalf("unsupported Astra parameter = %v, provider calls = %d", err, calls.Load()) + } + }) + } + } +} + +func TestCodexAppTextCandidateProjectsTextWithoutContinuity(t *testing.T) { + for _, modelID := range []string{"gpt-5.6-sol", "gpt-6-astra"} { + t.Run(modelID, func(t *testing.T) { + var requests atomic.Int32 + fixture, decision := codexAppFixture(t, modelID, func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + if body["model"] != modelID || body["stream"] != true || body["store"] != false { + t.Errorf("wrong candidate protocol: %v", body) + } + writeCodexAppResponse(t, w, []map[string]any{ + {"type": "reasoning", "id": "rs_candidate", "encrypted_content": "private-candidate-continuity"}, + {"type": "message", "id": "msg_candidate", "content": []any{map[string]any{"type": "output_text", "text": "Candidate text."}}}, + }, true) + }) + ctx := decision(accountservice.LocalAppOperationTextCandidateGenerate, localappop.AppOperationIDTextCandidateGenerate) + messages := []*runtimev1.LocalAppTextCandidateMessage{{Role: "user", Text: "Draft candidate text"}} + response, err := fixture.service.GenerateLocalAppTextCandidate(ctx, &runtimev1.GenerateLocalAppTextCandidateRequest{Messages: messages}) + if err != nil { + t.Fatal(err) + } + if response.GetText() != "Candidate text." || response.GetFinishReason() != runtimev1.FinishReason_FINISH_REASON_STOP || strings.TrimSpace(response.GetTraceId()) == "" || requests.Load() != 1 { + t.Fatalf("candidate response = %v, provider requests = %d", response, requests.Load()) + } + for _, request := range []*runtimev1.GenerateLocalAppTextCandidateRequest{ + {Messages: messages, Temperature: proto.Float32(0)}, + {Messages: messages, TopP: proto.Float32(1)}, + {Messages: messages, MaxTokens: proto.Int32(32)}, + } { + response, err := fixture.service.GenerateLocalAppTextCandidate(ctx, request) + if reason, _ := grpcerr.ExtractReasonCode(err); reason != runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED || response != nil || requests.Load() != 1 { + t.Fatalf("unsupported candidate sampling = %v, response = %v, provider requests = %d", err, response, requests.Load()) + } + } + }) + } +} + +func TestCodexAppTextCandidateProjectionRejectsInvalidItems(t *testing.T) { + textItem := &runtimev1.TextOutputItem{Item: &runtimev1.TextOutputItem_Text{Text: &runtimev1.TextOutputText{Text: "Candidate text."}}} + carrier := &runtimev1.ReasoningContinuityCarrier{Kind: "openai_codex.responses.encrypted-reasoning", Version: 1, Payload: []byte("opaque")} + for _, test := range []struct { + name string + output *runtimev1.TextGenerateOutput + }{ + { + name: "invalid continuity", + output: &runtimev1.TextGenerateOutput{Text: "Candidate text.", Items: []*runtimev1.TextOutputItem{ + textItem, {Item: &runtimev1.TextOutputItem_ReasoningContinuity{ReasoningContinuity: &runtimev1.ReasoningContinuityCarrier{Kind: carrier.Kind, Version: 1}}}, + }}, + }, + { + name: "tool call", + output: &runtimev1.TextGenerateOutput{Text: "Candidate text.", Items: []*runtimev1.TextOutputItem{ + textItem, {Item: &runtimev1.TextOutputItem_ToolCall{ToolCall: &runtimev1.ToolCall{Id: "call-1", Name: "lookup", ArgumentsJson: `{}`}}}, + }}, + }, + { + name: "continuity only", + output: &runtimev1.TextGenerateOutput{Items: []*runtimev1.TextOutputItem{ + {Item: &runtimev1.TextOutputItem_ReasoningContinuity{ReasoningContinuity: carrier}}, + }}, + }, + } { + t.Run(test.name, func(t *testing.T) { + if _, valid := canonicalPlainText(test.output); valid { + t.Fatalf("non-candidate output accepted: %v", test.output) + } + }) + } +} + +func TestCodexAppTextContentBudgetUsesOriginalToolJSON(t *testing.T) { + arguments := `{"values":[` + strings.TrimSuffix(strings.Repeat("1e20,", 16*1024), ",") + `]}` + var textBytes atomic.Int32 + textBytes.Store(100 * 1024) + fixture, decision := codexAppFixture(t, "gpt-5.6-sol", func(w http.ResponseWriter, r *http.Request) { + writeCodexAppResponse(t, w, []map[string]any{ + {"type": "reasoning", "id": "rs_budget", "encrypted_content": strings.Repeat("A", 60*1024)}, + {"type": "function_call", "id": "fc_budget", "call_id": "call-budget", "name": "numbers", "arguments": arguments}, + {"type": "message", "id": "msg_budget", "content": []any{map[string]any{"type": "output_text", "text": strings.Repeat("x", int(textBytes.Load()))}}}, + }, true) + }) + schema, err := structpb.NewStruct(map[string]any{ + "type": "object", "properties": map[string]any{"values": map[string]any{"type": "array", "items": map[string]any{"type": "number"}}}, + "required": []any{"values"}, + }) + if err != nil { + t.Fatal(err) + } + input := &runtimev1.StreamLocalAppTextTurnRequest{ + Messages: []*runtimev1.LocalAppTextCandidateMessage{{Role: "user", Text: "Return the numbers"}}, + Tools: []*runtimev1.ToolSpec{{Kind: runtimev1.ToolSpecKind_TOOL_SPEC_KIND_FUNCTION, Name: "numbers", InputSchema: schema}}, + } + execute := func() (*runtimev1.ExecuteLocalAppScenarioResponse, error) { + return fixture.service.ExecuteLocalAppScenario(decision(accountservice.LocalAppOperationScenarioExecute, localappop.AppOperationIDScenarioExecute), + &runtimev1.ExecuteLocalAppScenarioRequest{Spec: &runtimev1.ExecuteLocalAppScenarioRequest_TextGenerate{TextGenerate: input}}) + } + first, err := execute() + if err != nil { + t.Fatal(err) + } + items := first.GetTextGenerate().GetItems() + if len(items) != 3 || items[1].GetToolCall().GetArgumentsJson() != arguments { + t.Fatal("the original tool JSON did not survive the adapter") + } + remaining := maxLocalAppTextTurnTotalBytes - proto.Size(items[0].GetReasoningContinuity()) - proto.Size(items[1].GetToolCall()) + textBytes.Store(int32(remaining)) + if _, err := execute(); err != nil { + t.Fatalf("exact content budget rejected: %v", err) + } + textBytes.Store(int32(remaining + 1)) + _, err = execute() + if reason, ok := grpcerr.ExtractReasonCode(err); !ok || reason != runtimev1.ReasonCode_AI_OUTPUT_INVALID { + t.Fatalf("excess content budget accepted: %v", err) + } +} + +func TestCodexAppTextMissingTerminalAndCancelNeverComplete(t *testing.T) { + for _, cancel := range []bool{false, true} { + t.Run(fmt.Sprint(cancel), func(t *testing.T) { + started, closed := make(chan struct{}), make(chan struct{}) + fixture, decision := codexAppFixture(t, "gpt-5.6-sol", func(w http.ResponseWriter, r *http.Request) { + writeCodexAppResponse(t, w, []map[string]any{{"type": "function_call", "id": "fc_1", "call_id": "call_1", "name": "lookup", "arguments": `{"query":"one"}`}}, false) + close(started) + if cancel { + <-r.Context().Done() + close(closed) + } + }) + ctx, stop := context.WithCancel(decision(accountservice.LocalAppOperationTextTurnStream, localappop.AppOperationIDTextTurnStream)) + defer stop() + stream := &mockLocalAppTextTurnStream{ctx: ctx} + done := make(chan error, 1) + go func() { + done <- fixture.service.StreamLocalAppTextTurn(&runtimev1.StreamLocalAppTextTurnRequest{Messages: []*runtimev1.LocalAppTextCandidateMessage{{Role: "user", Text: "look up"}}, Tools: []*runtimev1.ToolSpec{localAppLookupTool(t)}}, stream) + }() + select { + case <-started: + case err := <-done: + t.Fatalf("request did not dispatch: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("request did not start") + } + if cancel { + stop() + select { + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("HTTP did not cancel") + } + } + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("request did not settle") + } + for _, event := range stream.events { + if event.GetCompleted() != nil { + t.Fatal("unfinished step completed") + } + } + }) + } +} diff --git a/runtime/internal/services/ai/embedding_space.go b/runtime/internal/services/ai/embedding_space.go new file mode 100644 index 0000000000..f100a63d1b --- /dev/null +++ b/runtime/internal/services/ai/embedding_space.go @@ -0,0 +1,81 @@ +package ai + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "github.com/nimiplatform/nimi/runtime/internal/grpcerr" + "github.com/nimiplatform/nimi/runtime/internal/services/connector" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" +) + +// @nimi-authority: rule.nimi.runtime.ai-provider.embedding-space-identity +// This is vector compatibility metadata, not an execution or configuration key. +// Hashing captured semantics lets Apps compare spaces without exposing their +// private composition or re-embedding every indexed document to compare it. +func embeddingSpaceID(kind, connectorRef string, vectors []*runtimev1.EmbeddingVector, parts ...proto.Message) (string, error) { + if len(vectors) == 0 || len(vectors[0].GetValues()) == 0 { + return "", grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID) + } + dimensions := len(vectors[0].GetValues()) + for _, vector := range vectors { + if len(vector.GetValues()) != dimensions { + return "", grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID) + } + } + encoded := make([][]byte, 0, len(parts)) + for _, part := range parts { + value, err := (proto.MarshalOptions{Deterministic: true}).Marshal(part) + if err != nil { + return "", grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID) + } + if len(value) == 0 { + value = nil + } + encoded = append(encoded, value) + } + payload, err := json.Marshal(struct { + Kind string + ConnectorRef string + Dimensions int + Parts [][]byte + }{kind, connectorRef, dimensions, encoded}) + if err != nil { + return "", grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID) + } + digest := sha256.Sum256(payload) + return "emb-v1-" + hex.EncodeToString(digest[:]), nil +} + +func localEmbeddingSpaceID(effective *localEmbedEffectiveInputs, vectors []*runtimev1.EmbeddingVector) (string, error) { + if effective == nil || effective.effectiveInputIdentity == nil { + return "", grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID) + } + identity := proto.Clone(effective.effectiveInputIdentity).(*runtimev1.LoadoutEffectiveInputIdentity) + // A new Loadout or re-import of identical content does not change its space. + identity.LoadoutId = "" + for _, axis := range identity.ModelAxes { + axis.ModelAssetId = "" + } + return embeddingSpaceID("local", "", vectors, identity) +} + +func cloudEmbeddingSpaceID(effective *cloudEmbedEffectiveInputs, vectors []*runtimev1.EmbeddingVector) (string, error) { + // Catalog identity admits the captured target, but its provider-wide + // inventory revision does not change this model's embedding semantics. + target := proto.Clone(effective.rawTarget).(*structpb.Struct) + delete(target.Fields, "remoteModelCatalogId") + connectorTarget := &structpb.Struct{Fields: map[string]*structpb.Value{ + "endpoint": structpb.NewStringValue(connector.ResolveEndpoint( + effective.connector.Provider, strings.TrimSpace(effective.connector.Endpoint))), + "authKind": structpb.NewStringValue(effective.connector.AuthKind.String()), + "providerAuthProfile": structpb.NewStringValue(effective.connector.ProviderAuthProfile), + }} + return embeddingSpaceID("cloud", effective.connector.ConnectorID, vectors, + effective.implementation, target, effective.defaults, connectorTarget) +} diff --git a/runtime/internal/services/ai/embedding_space_test.go b/runtime/internal/services/ai/embedding_space_test.go new file mode 100644 index 0000000000..ba2e4ac611 --- /dev/null +++ b/runtime/internal/services/ai/embedding_space_test.go @@ -0,0 +1,98 @@ +package ai + +import ( + "testing" + + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "github.com/nimiplatform/nimi/runtime/internal/services/connector" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestLocalEmbeddingSpaceTracksContentNotLoadoutOrRequestIdentity(t *testing.T) { + identity := &runtimev1.LoadoutEffectiveInputIdentity{ + LoadoutId: "loadout-a", CapabilityContract: "text.embed", RecipeId: "embedding-recipe", RecipeRevision: "1", + Implementation: &runtimev1.CapabilityImplementationIdentity{ImplementationId: "embedding", DriverId: "driver", DriverDialect: "embed/v1"}, + ModelAxes: []*runtimev1.LoadoutEffectiveModelAxisIdentity{{SlotId: "model", ModelAssetId: "asset-a", ContentId: "content-a"}}, + } + vectors := []*runtimev1.EmbeddingVector{{Values: []float64{0.1, 0.2}}} + effective := &localEmbedEffectiveInputs{effectiveInputIdentity: identity, request: &runtimev1.TextEmbedScenarioSpec{Inputs: []string{"first"}}} + first, err := localEmbeddingSpaceID(effective, vectors) + if err != nil || first == "" { + t.Fatalf("space identity: %q %v", first, err) + } + copy := proto.Clone(identity).(*runtimev1.LoadoutEffectiveInputIdentity) + copy.LoadoutId = "new-loadout" + copy.ModelAxes[0].ModelAssetId = "reimported-asset" + effective.effectiveInputIdentity = copy + effective.displayName = "Renamed" + effective.request.Inputs = []string{"second", "third"} + same, err := localEmbeddingSpaceID(effective, append(vectors, vectors[0])) + if err != nil || first != same { + t.Fatalf("non-semantic changes invalidated vector space: %q %q %v", first, same, err) + } + copy.ModelAxes[0].ContentId = "content-b" + changed, err := localEmbeddingSpaceID(effective, vectors) + if err != nil || changed == first { + t.Fatalf("same-dimension model replacement reused vector space: %q %v", changed, err) + } + if identity.GetLoadoutId() != "loadout-a" || identity.ModelAxes[0].ModelAssetId != "asset-a" { + t.Fatal("space projection mutated captured attribution") + } +} + +func TestCloudEmbeddingSpaceTracksExecutionTargetNotCatalogRevision(t *testing.T) { + target, _ := structpb.NewStruct(map[string]any{"provider": "openai", "providerModelId": "embedding-a", "remoteModelCatalogId": "catalog-a"}) + effective := &cloudEmbedEffectiveInputs{ + implementation: &runtimev1.CapabilityImplementationIdentity{ImplementationId: "cloud-embed", DriverId: "driver", DriverDialect: "embed/v1"}, + rawTarget: target, connector: connector.ConnectorRecord{ + ConnectorID: "connector-a", Provider: "openai", Endpoint: "https://embeddings.example.test", + AuthKind: runtimev1.ConnectorAuthKind_CONNECTOR_AUTH_KIND_API_KEY, + }, + } + vectors := []*runtimev1.EmbeddingVector{{Values: []float64{0.1, 0.2}}} + first, err := cloudEmbeddingSpaceID(effective, vectors) + if err != nil { + t.Fatal(err) + } + effective.defaults = &structpb.Struct{} + effective.connector.Label = "Renamed" + effective.connector.UpdatedAt++ + effective.connector.CredentialCustodyRef = "new-custody" + effective.rawTarget.Fields["remoteModelCatalogId"] = structpb.NewStringValue("catalog-b") + unchanged, err := cloudEmbeddingSpaceID(effective, vectors) + if err != nil || unchanged != first { + t.Fatalf("non-semantic changes invalidated the embedding space: %q %v", unchanged, err) + } + if effective.rawTarget.Fields["remoteModelCatalogId"].GetStringValue() != "catalog-b" { + t.Fatal("space projection mutated captured catalog admission") + } + for _, change := range []func(){ + func() { effective.connector.ConnectorID = "connector-b" }, + func() { effective.connector.Endpoint = "https://other-embeddings.example.test" }, + func() { effective.rawTarget.Fields["providerModelId"] = structpb.NewStringValue("embedding-b") }, + func() { effective.implementation.DriverDialect = "embed/v2" }, + } { + change() + next, err := cloudEmbeddingSpaceID(effective, vectors) + if err != nil || next == first { + t.Fatalf("changed composition reused space: %q %v", next, err) + } + first = next + } +} + +func TestEmbeddingSpaceRequiresConsistentDimensions(t *testing.T) { + for _, vectors := range [][]*runtimev1.EmbeddingVector{ + nil, {nil}, {{Values: []float64{1}}, {Values: []float64{1, 2}}}, + } { + if _, err := embeddingSpaceID("local", "", vectors); err == nil { + t.Fatal("invalid embedding dimensions accepted") + } + } + one, _ := embeddingSpaceID("local", "", []*runtimev1.EmbeddingVector{{Values: []float64{1}}}) + two, _ := embeddingSpaceID("local", "", []*runtimev1.EmbeddingVector{{Values: []float64{1, 2}}}) + if one == two { + t.Fatal("different dimensions share a vector space") + } +} diff --git a/runtime/internal/services/ai/local_app_scenario.go b/runtime/internal/services/ai/local_app_scenario.go index d9b23b17d2..16bfd0e5f9 100644 --- a/runtime/internal/services/ai/local_app_scenario.go +++ b/runtime/internal/services/ai/local_app_scenario.go @@ -163,8 +163,8 @@ func localAppValidTraceID(traceID string) bool { // ExecuteLocalAppScenario preserves the third-party Local App synchronous // scenario contract while delegating route composition, scheduling, Driver // mapping, metering, and execution to the Scenario owner. The App supplies a -// closed-set SYNC spec only: no route, implementation, target, model, -// tool, stream, or job field. +// closed-set SYNC spec only, including bounded function-tool text behavior: +// no route, implementation, target, model, stream, or job field. func (s *Service) ExecuteLocalAppScenario(ctx context.Context, req *runtimev1.ExecuteLocalAppScenarioRequest) (*runtimev1.ExecuteLocalAppScenarioResponse, error) { decision, err := localAppScenarioDecision(ctx, accountservice.LocalAppOperationScenarioExecute, localappop.AppOperationIDScenarioExecute) if err != nil { @@ -183,7 +183,20 @@ func (s *Service) ExecuteLocalAppScenario(ctx context.Context, req *runtimev1.Ex if err != nil { return nil, err } - if result == nil || !localAppTextCandidateFinishReason(result.GetFinishReason()) || !localAppValidTraceID(result.GetTraceId()) { + if result == nil || !localAppValidTraceID(result.GetTraceId()) { + return nil, grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID) + } + if scenarioType == runtimev1.ScenarioType_SCENARIO_TYPE_TEXT_GENERATE { + text, err := projectLocalAppTextOutput(result.GetOutput().GetTextGenerate(), result.GetFinishReason(), ownerSpec.GetTextGenerate()) + if err != nil { + return nil, err + } + return &runtimev1.ExecuteLocalAppScenarioResponse{ + Output: &runtimev1.ExecuteLocalAppScenarioResponse_TextGenerate{TextGenerate: text}, + TraceId: result.GetTraceId(), + }, nil + } + if !localAppTextCandidateFinishReason(result.GetFinishReason()) { return nil, grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID) } switch scenarioType { @@ -193,6 +206,9 @@ func (s *Service) ExecuteLocalAppScenario(ctx context.Context, req *runtimev1.Ex return nil, grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID) } vectors := embed.GetVectors() + if !localAppExactText(embed.GetSpaceId(), 128) { + return nil, grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID) + } if len(vectors) == 0 || len(vectors) > maxLocalAppEmbeddingVectors { return nil, grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID) } @@ -207,7 +223,7 @@ func (s *Service) ExecuteLocalAppScenario(ctx context.Context, req *runtimev1.Ex } } return &runtimev1.ExecuteLocalAppScenarioResponse{ - Output: &runtimev1.ExecuteLocalAppScenarioResponse_TextEmbed{TextEmbed: &runtimev1.LocalAppTextEmbedOutput{Vectors: vectors}}, + Output: &runtimev1.ExecuteLocalAppScenarioResponse_TextEmbed{TextEmbed: &runtimev1.LocalAppTextEmbedOutput{Vectors: vectors, SpaceId: embed.GetSpaceId()}}, TraceId: result.GetTraceId(), }, nil case runtimev1.ScenarioType_SCENARIO_TYPE_IMAGE_GENERATE: @@ -233,6 +249,14 @@ func validateLocalAppScenarioExecuteRequest(req *runtimev1.ExecuteLocalAppScenar return nil, runtimev1.ScenarioType_SCENARIO_TYPE_UNSPECIFIED, grpcerr.WithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_PROTOCOL_ENVELOPE_INVALID) } switch spec := req.GetSpec().(type) { + case *runtimev1.ExecuteLocalAppScenarioRequest_TextGenerate: + text, err := localAppTextGenerateSpec(spec.TextGenerate) + if err != nil { + return nil, runtimev1.ScenarioType_SCENARIO_TYPE_UNSPECIFIED, err + } + return &runtimev1.ScenarioSpec{Spec: &runtimev1.ScenarioSpec_TextGenerate{ + TextGenerate: text, + }}, runtimev1.ScenarioType_SCENARIO_TYPE_TEXT_GENERATE, nil case *runtimev1.ExecuteLocalAppScenarioRequest_TextEmbed: inputs, err := validateLocalAppEmbedInputs(spec.TextEmbed.GetInputs()) if err != nil { diff --git a/runtime/internal/services/ai/local_app_text_behavior.go b/runtime/internal/services/ai/local_app_text_behavior.go new file mode 100644 index 0000000000..46936ad649 --- /dev/null +++ b/runtime/internal/services/ai/local_app_text_behavior.go @@ -0,0 +1,237 @@ +package ai + +import ( + "strings" + + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "github.com/nimiplatform/nimi/runtime/internal/grpcerr" + "github.com/nimiplatform/nimi/runtime/internal/textbehavior" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/proto" +) + +const ( + maxLocalAppTextMessages = 128 + maxLocalAppTextTools = 64 + maxLocalAppTextRequestBytes = 1 << 20 +) + +func localAppTextInputInvalid() error { + return grpcerr.WithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_AI_INPUT_INVALID) +} + +// localAppTextGenerateSpec is the shared single-step input boundary. It has no +// route, credential or cross-request execution state. +// @nimi-authority: rule.nimi.runtime.ai-provider.local-app-text-behaviors +func localAppTextGenerateSpec(req *runtimev1.StreamLocalAppTextTurnRequest) (*runtimev1.TextGenerateScenarioSpec, error) { + if req == nil || len(req.GetMessages()) == 0 || len(req.GetMessages()) > maxLocalAppTextMessages || + len(req.GetTools()) > maxLocalAppTextTools || proto.Size(req) > maxLocalAppTextRequestBytes { + return nil, localAppTextInputInvalid() + } + if invalidLocalAppTextTurnScalar(req.Temperature, 0, 2) || invalidLocalAppTextTurnScalar(req.TopP, 0, 1) || + invalidLocalAppTextTurnScalar(req.PresencePenalty, -2, 2) || invalidLocalAppTextTurnScalar(req.FrequencyPenalty, -2, 2) || + (req.MaxTokens != nil && req.GetMaxTokens() < 0) || (req.TopK != nil && req.GetTopK() < 0) { + return nil, localAppTextInputInvalid() + } + for _, stop := range req.GetStop() { + if strings.TrimSpace(stop) == "" { + return nil, localAppTextInputInvalid() + } + } + spec := &runtimev1.TextGenerateScenarioSpec{ + Temperature: localAppOptionalFloat32(req.Temperature), TopP: localAppOptionalFloat32(req.TopP), + MaxTokens: localAppOptionalInt32(req.MaxTokens), TopK: localAppOptionalInt32(req.TopK), + PresencePenalty: localAppOptionalFloat32(req.PresencePenalty), FrequencyPenalty: localAppOptionalFloat32(req.FrequencyPenalty), + Stop: append([]string(nil), req.GetStop()...), Seed: localAppOptionalInt64(req.Seed), + ToolChoice: req.GetToolChoice(), ToolChoiceName: req.GetToolChoiceName(), + } + declared := make(map[string]*runtimev1.ToolSpec, len(req.GetTools())) + for _, tool := range req.GetTools() { + if tool == nil || tool.GetKind() != runtimev1.ToolSpecKind_TOOL_SPEC_KIND_FUNCTION || + !localAppBoundedIdentifier(tool.GetName()) || tool.GetInputSchema() == nil || + tool.GetProviderToolId() != "" || tool.GetProviderArgs() != nil || tool.GetProviderMetadata() != nil { + return nil, localAppTextInputInvalid() + } + if _, duplicate := declared[tool.GetName()]; duplicate { + return nil, localAppTextInputInvalid() + } + if _, err := textbehavior.CompileJSONSchema(tool.GetInputSchema().AsMap()); err != nil { + return nil, localAppTextInputInvalid() + } + declared[tool.GetName()] = tool + spec.Tools = append(spec.Tools, proto.Clone(tool).(*runtimev1.ToolSpec)) + } + seenUser := false + for index, message := range req.GetMessages() { + if message == nil { + return nil, localAppTextInputInvalid() + } + role := message.GetRole() + items := message.GetTurnItems() + if len(items) > 0 { + if role != "assistant" || message.GetText() != "" { + return nil, localAppTextInputInvalid() + } + for _, item := range items { + if err := validateLocalAppTextTurnItem(item, declared); err != nil { + return nil, err + } + } + } else if strings.TrimSpace(message.GetText()) == "" { + return nil, localAppTextInputInvalid() + } + switch role { + case "system": + if index != 0 || len(items) != 0 { + return nil, localAppTextInputInvalid() + } + spec.SystemPrompt = message.GetText() + continue + case "user": + seenUser = true + case "assistant": + default: + return nil, localAppTextInputInvalid() + } + converted := &runtimev1.ChatMessage{Role: role, Content: message.GetText()} + if role == "assistant" && len(items) == 0 { + converted.Content = "" + converted.TurnItems = []*runtimev1.TextTurnItem{{Item: &runtimev1.TextTurnItem_Output{ + Output: &runtimev1.TextOutputItem{Item: &runtimev1.TextOutputItem_Text{Text: &runtimev1.TextOutputText{Text: message.GetText()}}}, + }}} + } + for _, item := range items { + converted.TurnItems = append(converted.TurnItems, proto.Clone(item).(*runtimev1.TextTurnItem)) + } + spec.Input = append(spec.Input, converted) + } + if !seenUser { + return nil, localAppTextInputInvalid() + } + if format := req.GetResponseFormat(); format != nil { + switch format.GetKind() { + case runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_UNSPECIFIED, runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_TEXT, + runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_OBJECT: + if format.GetJsonSchema() != nil || format.GetStrict() { + return nil, localAppTextInputInvalid() + } + case runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_SCHEMA: + if format.GetJsonSchema() == nil { + return nil, localAppTextInputInvalid() + } + if _, err := textbehavior.CompileJSONSchema(format.GetJsonSchema().AsMap()); err != nil { + return nil, localAppTextInputInvalid() + } + default: + return nil, localAppTextInputInvalid() + } + spec.ResponseFormat = proto.Clone(format).(*runtimev1.ResponseFormat) + } + if err := validateTextBehaviorToolRequestShape(spec); err != nil { + return nil, err + } + return spec, nil +} + +func validateLocalAppTextTurnItem(item *runtimev1.TextTurnItem, tools map[string]*runtimev1.ToolSpec) error { + if item == nil { + return localAppTextInputInvalid() + } + if output := item.GetOutput(); output != nil { + if text := output.GetText(); text != nil && text.GetText() != "" { + return nil + } + if call := output.GetToolCall(); call != nil { + return validateLocalAppTextToolCall(call, tools) + } + if textbehavior.ValidContinuity(output.GetReasoningContinuity()) { + return nil + } + return localAppTextInputInvalid() + } + result := item.GetToolResult() + if result == nil || result.GetPreliminary() || result.GetDynamic() || result.GetProviderMetadata() != nil || result.GetResult() == nil { + return localAppTextInputInvalid() + } + return nil // The shared transcript validator checks prior call id and name. +} + +func validateLocalAppTextToolCall(call *runtimev1.ToolCall, tools map[string]*runtimev1.ToolSpec) error { + if call == nil || !localAppBoundedIdentifier(call.GetId()) || !localAppBoundedIdentifier(call.GetName()) || + call.GetDynamic() || call.GetProviderMetadata() != nil || tools[call.GetName()] == nil { + return invalidTextBehaviorToolTranscriptError() + } + if err := textbehavior.ValidateToolArguments(tools[call.GetName()], call.GetArgumentsJson()); err != nil { + return invalidTextBehaviorToolTranscriptError() + } + return nil +} + +func localAppTextFinishReason(reason runtimev1.FinishReason) bool { + return localAppTextCandidateFinishReason(reason) || reason == runtimev1.FinishReason_FINISH_REASON_TOOL_CALL +} + +func localAppTextOutputToolInvalid() error { + return grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_TOOL_CALL_INVALID) +} + +func localAppTextOutputChoiceValid(choice runtimev1.ToolChoiceMode, name string, call *runtimev1.ToolCall) bool { + return choice != runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_NONE && + (choice != runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_TOOL || call.GetName() == name) +} + +func localAppTextRequiresTool(choice runtimev1.ToolChoiceMode) bool { + return choice == runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_REQUIRED || choice == runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_TOOL +} + +func projectLocalAppTextOutput(output *runtimev1.TextGenerateOutput, finish runtimev1.FinishReason, spec *runtimev1.TextGenerateScenarioSpec) (*runtimev1.LocalAppTextGenerateOutput, error) { + invalid := func() (*runtimev1.LocalAppTextGenerateOutput, error) { + return nil, grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID) + } + if output == nil || len(output.GetItems()) == 0 || !localAppTextFinishReason(finish) { + return invalid() + } + declared := make(map[string]*runtimev1.ToolSpec, len(spec.GetTools())) + for _, tool := range spec.GetTools() { + declared[tool.GetName()] = tool + } + result := &runtimev1.LocalAppTextGenerateOutput{FinishReason: finish} + seenCalls := make(map[string]struct{}) + bytes := 0 + hasPrimary := false + for _, item := range output.GetItems() { + if item == nil { + return invalid() + } + if text := item.GetText(); text != nil && text.GetText() != "" { + bytes += len(text.GetText()) + hasPrimary = true + } else if call := item.GetToolCall(); call != nil { + if err := validateLocalAppTextToolCall(call, declared); err != nil || + !localAppTextOutputChoiceValid(spec.GetToolChoice(), spec.GetToolChoiceName(), call) { + return nil, localAppTextOutputToolInvalid() + } + if _, duplicate := seenCalls[call.GetId()]; duplicate { + return nil, localAppTextOutputToolInvalid() + } + seenCalls[call.GetId()] = struct{}{} + bytes += proto.Size(call) + hasPrimary = true + } else if carrier := item.GetReasoningContinuity(); textbehavior.ValidContinuity(carrier) { + bytes += proto.Size(carrier) + } else { + return invalid() + } + if bytes > maxLocalAppTextTurnTotalBytes { + return invalid() + } + result.Items = append(result.Items, proto.Clone(item).(*runtimev1.TextOutputItem)) + } + if !hasPrimary || finish == runtimev1.FinishReason_FINISH_REASON_TOOL_CALL && len(seenCalls) == 0 { + return invalid() + } + if localAppTextRequiresTool(spec.GetToolChoice()) && len(seenCalls) == 0 { + return nil, localAppTextOutputToolInvalid() + } + return result, nil +} diff --git a/runtime/internal/services/ai/local_app_text_behavior_test.go b/runtime/internal/services/ai/local_app_text_behavior_test.go new file mode 100644 index 0000000000..d179acb483 --- /dev/null +++ b/runtime/internal/services/ai/local_app_text_behavior_test.go @@ -0,0 +1,166 @@ +package ai + +import ( + "context" + "strings" + "testing" + + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "github.com/nimiplatform/nimi/runtime/internal/localexecution" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" +) + +func localAppLookupTool(t *testing.T) *runtimev1.ToolSpec { + t.Helper() + schema, err := structpb.NewStruct(map[string]any{ + "type": "object", "properties": map[string]any{"query": map[string]any{"type": "string"}}, + "required": []any{"query"}, "additionalProperties": false, + }) + if err != nil { + t.Fatal(err) + } + return &runtimev1.ToolSpec{Kind: runtimev1.ToolSpecKind_TOOL_SPEC_KIND_FUNCTION, Name: "lookup", InputSchema: schema} +} + +func localAppLookupCall() *runtimev1.ToolCall { + return &runtimev1.ToolCall{Id: "lookup-1", Name: "lookup", ArgumentsJson: `{"query":"runtime tools"}`} +} + +func TestLocalAppTextBehaviorAcceptsOrderedToolTranscript(t *testing.T) { + value, _ := structpb.NewValue(map[string]any{"sources": []any{"result"}}) + request := &runtimev1.StreamLocalAppTextTurnRequest{ + Messages: []*runtimev1.LocalAppTextCandidateMessage{ + {Role: "system", Text: "Return a sourced answer."}, + {Role: "user", Text: "Research runtime tools."}, + {Role: "assistant", TurnItems: []*runtimev1.TextTurnItem{ + {Item: &runtimev1.TextTurnItem_Output{Output: &runtimev1.TextOutputItem{Item: &runtimev1.TextOutputItem_ToolCall{ToolCall: localAppLookupCall()}}}}, + {Item: &runtimev1.TextTurnItem_ToolResult{ToolResult: &runtimev1.ToolResult{ToolCallId: "lookup-1", ToolName: "lookup", Result: value}}}, + }}, + }, + Tools: []*runtimev1.ToolSpec{localAppLookupTool(t)}, ToolChoice: runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_NONE, + } + spec, err := localAppTextGenerateSpec(request) + if err != nil { + t.Fatal(err) + } + if spec.GetSystemPrompt() != request.Messages[0].Text || len(spec.GetInput()) != 2 || len(spec.GetInput()[1].GetTurnItems()) != 2 { + t.Fatalf("ordered transcript was lost: %v", spec) + } + if spec.GetTools()[0] == request.GetTools()[0] { + t.Fatal("request tool was not captured") + } + request.Messages[2].TurnItems[1].GetToolResult().ToolCallId = "unknown" + _, err = localAppTextGenerateSpec(request) + assertLocalAppTextCandidateError(t, err, codes.InvalidArgument, runtimev1.ReasonCode_AI_TOOL_CALL_INVALID) +} + +func TestLocalAppTextBehaviorRejectsPrivilegedOrInvalidValues(t *testing.T) { + base := &runtimev1.StreamLocalAppTextTurnRequest{ + Messages: []*runtimev1.LocalAppTextCandidateMessage{{Role: "user", Text: "lookup"}}, + Tools: []*runtimev1.ToolSpec{localAppLookupTool(t)}, + } + for name, mutate := range map[string]func(*runtimev1.StreamLocalAppTextTurnRequest){ + "provider tool": func(r *runtimev1.StreamLocalAppTextTurnRequest) { + r.Tools[0].Kind = runtimev1.ToolSpecKind_TOOL_SPEC_KIND_PROVIDER + }, + "provider metadata": func(r *runtimev1.StreamLocalAppTextTurnRequest) { r.Tools[0].ProviderMetadata = &structpb.Struct{} }, + "too much input": func(r *runtimev1.StreamLocalAppTextTurnRequest) { + r.Messages[0].Text = strings.Repeat("x", maxLocalAppTextRequestBytes+1) + }, + "too many messages": func(r *runtimev1.StreamLocalAppTextTurnRequest) { + for len(r.Messages) <= maxLocalAppTextMessages { + r.Messages = append(r.Messages, r.Messages[0]) + } + }, + "mixed transcript": func(r *runtimev1.StreamLocalAppTextTurnRequest) { + r.Messages[0].TurnItems = []*runtimev1.TextTurnItem{{Item: &runtimev1.TextTurnItem_Output{Output: &runtimev1.TextOutputItem{Item: &runtimev1.TextOutputItem_Text{Text: &runtimev1.TextOutputText{Text: "other"}}}}}} + }, + } { + t.Run(name, func(t *testing.T) { + request := proto.Clone(base).(*runtimev1.StreamLocalAppTextTurnRequest) + mutate(request) + _, err := localAppTextGenerateSpec(request) + assertLocalAppTextCandidateError(t, err, codes.InvalidArgument, runtimev1.ReasonCode_AI_INPUT_INVALID) + }) + } +} + +func TestLocalAppTextBehaviorPreservesResponseSchemaAndNarrowCandidate(t *testing.T) { + schema, _ := structpb.NewStruct(map[string]any{"type": "object"}) + request := &runtimev1.StreamLocalAppTextTurnRequest{ + Messages: []*runtimev1.LocalAppTextCandidateMessage{{Role: "user", Text: "Return JSON."}}, + ResponseFormat: &runtimev1.ResponseFormat{Kind: runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_SCHEMA, JsonSchema: schema, Strict: true}, + } + spec, err := localAppTextGenerateSpec(request) + if err != nil || !proto.Equal(spec.GetResponseFormat(), request.GetResponseFormat()) { + t.Fatalf("schema: %v %v", spec, err) + } + candidate := validLocalAppTextCandidateRequest() + candidate.Messages[0].TurnItems = []*runtimev1.TextTurnItem{{}} + _, _, err = validateLocalAppTextCandidateRequest(candidate) + if err == nil { + t.Fatal("narrow candidate admitted ordered items") + } +} + +func TestLocalAppTextBehaviorStreamPreservesMixedItemOrder(t *testing.T) { + sink := &mockLocalAppTextTurnStream{ctx: context.Background()} + bridge := &localAppTextTurnStreamBridge{ServerStreamingServer: sink, tools: map[string]*runtimev1.ToolSpec{"lookup": localAppLookupTool(t)}} + for _, payload := range []*runtimev1.ScenarioStreamDelta{ + textOutputDelta(0, "Searching.", true), + toolCallOutputDelta(1, localAppLookupCall()), + textOutputDelta(2, "Checking sources.", true), + } { + if err := bridge.Send(&runtimev1.StreamScenarioEvent{TraceId: "trace-tools", Payload: &runtimev1.StreamScenarioEvent_Delta{Delta: payload}}); err != nil { + t.Fatal(err) + } + } + if err := bridge.Send(&runtimev1.StreamScenarioEvent{TraceId: "trace-tools", Payload: &runtimev1.StreamScenarioEvent_Completed{Completed: &runtimev1.ScenarioStreamCompleted{FinishReason: runtimev1.FinishReason_FINISH_REASON_TOOL_CALL}}}); err != nil { + t.Fatal(err) + } + if len(sink.events) != 4 || sink.events[1].GetToolCall().GetItemIndex() != 1 || sink.events[2].GetDelta().GetItemIndex() != 2 || sink.events[3].GetCompleted() == nil { + t.Fatalf("mixed output order: %v", sink.events) + } + for index, event := range sink.events { + if event.GetSequence() != uint64(index+1) { + t.Fatal("non-dense public sequence") + } + } +} + +func TestLocalAppTextBehaviorRejectsInvalidOutputCallsAndChoice(t *testing.T) { + tool := localAppLookupTool(t) + for name, mutate := range map[string]func(*runtimev1.ToolCall){ + "unknown tool": func(c *runtimev1.ToolCall) { c.Name = "other" }, + "incomplete arguments": func(c *runtimev1.ToolCall) { c.ArgumentsJson = `{"query":` }, + "wrong schema": func(c *runtimev1.ToolCall) { c.ArgumentsJson = `{"query":7}` }, + } { + t.Run(name, func(t *testing.T) { + call := localAppLookupCall() + mutate(call) + bridge := &localAppTextTurnStreamBridge{ServerStreamingServer: &mockLocalAppTextTurnStream{ctx: context.Background()}, tools: map[string]*runtimev1.ToolSpec{"lookup": tool}} + err := bridge.Send(&runtimev1.StreamScenarioEvent{Payload: &runtimev1.StreamScenarioEvent_Delta{Delta: toolCallOutputDelta(0, call)}}) + assertLocalAppTextCandidateError(t, err, codes.Internal, runtimev1.ReasonCode_AI_TOOL_CALL_INVALID) + }) + } + _, err := projectLocalAppTextOutput(canonicalTextGenerateOutput("ignored tool choice", nil), runtimev1.FinishReason_FINISH_REASON_STOP, &runtimev1.TextGenerateScenarioSpec{Tools: []*runtimev1.ToolSpec{tool}, ToolChoice: runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_REQUIRED}) + assertLocalAppTextCandidateError(t, err, codes.Internal, runtimev1.ReasonCode_AI_TOOL_CALL_INVALID) +} + +func TestExecuteLocalAppTextScenarioUsesAppAIConfig(t *testing.T) { + svc := newTestService(nil) + if err := overwriteAIConfigStoreForTest(context.Background(), svc.aiConfigStore, "account-1", appAIConfig("nimi.realm-persona-studio", localAppAIConfigIntent("text.generate"))); err != nil { + t.Fatal(err) + } + svc.SetLocalExecutionResolver(&mutableLocalExecutionResolver{projection: selectedTextExecutionForTest(t, "config-app", "app.gguf")}) + svc.SetLocalTextExecutionHost(&localTextHostStub{result: localexecution.TextResult{Text: "owner selected text", FinishReason: runtimev1.FinishReason_FINISH_REASON_STOP}}) + response, err := svc.ExecuteLocalAppScenario(localAppScenarioExecuteContext(), &runtimev1.ExecuteLocalAppScenarioRequest{Spec: &runtimev1.ExecuteLocalAppScenarioRequest_TextGenerate{TextGenerate: &runtimev1.StreamLocalAppTextTurnRequest{Messages: []*runtimev1.LocalAppTextCandidateMessage{{Role: "user", Text: "Generate text."}}}}}) + if err != nil { + t.Fatal(err) + } + if response.GetTextGenerate() == nil || response.GetTextGenerate().GetItems()[0].GetText().GetText() != "owner selected text" || response.GetTraceId() == "" { + t.Fatalf("text response: %v", response) + } +} diff --git a/runtime/internal/services/ai/local_app_text_candidate.go b/runtime/internal/services/ai/local_app_text_candidate.go index d0c5ded99a..d475bb46dc 100644 --- a/runtime/internal/services/ai/local_app_text_candidate.go +++ b/runtime/internal/services/ai/local_app_text_candidate.go @@ -9,6 +9,7 @@ import ( "github.com/nimiplatform/nimi/runtime/internal/grpcerr" "github.com/nimiplatform/nimi/runtime/internal/localappop" accountservice "github.com/nimiplatform/nimi/runtime/internal/services/account" + "github.com/nimiplatform/nimi/runtime/internal/textbehavior" "google.golang.org/grpc/codes" "google.golang.org/protobuf/proto" ) @@ -24,6 +25,7 @@ const ( // contract while delegating route composition, spend disclosure, scheduling, // Driver mapping, metering, and execution to the post-I5 Scenario owner. The // App supplies no route, implementation, target, model, tool, or stream. +// @nimi-authority: rule.nimi.runtime.ai-provider.r107 func (s *Service) GenerateLocalAppTextCandidate(ctx context.Context, req *runtimev1.GenerateLocalAppTextCandidateRequest) (*runtimev1.GenerateLocalAppTextCandidateResponse, error) { decision, ok := accountservice.AuthorizedLocalAppDecisionFromContext(ctx) if !ok || decision.Operation != accountservice.LocalAppOperationTextCandidateGenerate || @@ -81,14 +83,16 @@ func canonicalPlainText(output *runtimev1.TextGenerateOutput) (string, bool) { return "", false } var builder strings.Builder + // Candidate responses omit the Scenario's valid opaque continuity. for _, item := range output.GetItems() { - if item == nil || item.GetText() == nil { + if text := item.GetText(); text != nil { + builder.WriteString(text.GetText()) + } else if !textbehavior.ValidContinuity(item.GetReasoningContinuity()) { return "", false } - builder.WriteString(item.GetText().GetText()) } text := builder.String() - return text, text == output.GetText() + return text, text != "" && text == output.GetText() } func localAppTextCandidateFinishReason(reason runtimev1.FinishReason) bool { @@ -132,7 +136,7 @@ func validateLocalAppTextCandidateFields( totalBytes := 0 seenUser := false for _, message := range messages { - if message == nil { + if message == nil || len(message.GetTurnItems()) != 0 { return "", nil, grpcerr.WithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_PROTOCOL_ENVELOPE_INVALID) } role := message.GetRole() diff --git a/runtime/internal/services/ai/local_app_text_stream.go b/runtime/internal/services/ai/local_app_text_stream.go index 6926712632..a9daf858b1 100644 --- a/runtime/internal/services/ai/local_app_text_stream.go +++ b/runtime/internal/services/ai/local_app_text_stream.go @@ -2,12 +2,12 @@ package ai import ( "math" - "strings" runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" "github.com/nimiplatform/nimi/runtime/internal/grpcerr" "github.com/nimiplatform/nimi/runtime/internal/localappop" accountservice "github.com/nimiplatform/nimi/runtime/internal/services/account" + "github.com/nimiplatform/nimi/runtime/internal/textbehavior" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/protobuf/proto" @@ -22,55 +22,59 @@ const ( // StreamLocalAppTextTurn preserves the third-party Local App streaming text // contract while delegating route composition, scheduling, Driver mapping, // metering, and execution to the Scenario stream owner. The stream carries -// typed text increments and terminal finish or failure state only; raw chunks, -// reasoning traces, sources, and tool events fail closed before reaching the -// App. +// ordered text increments, complete function calls, opaque continuity and +// terminal state. Raw reasoning, sources and provider payloads stay private. +// @nimi-authority: rule.nimi.runtime.ai-provider.local-app-text-behaviors func (s *Service) StreamLocalAppTextTurn(req *runtimev1.StreamLocalAppTextTurnRequest, stream grpc.ServerStreamingServer[runtimev1.StreamLocalAppTextTurnEvent]) error { decision, err := localAppScenarioDecision(stream.Context(), accountservice.LocalAppOperationTextTurnStream, localappop.AppOperationIDTextTurnStream) if err != nil { return err } - systemPrompt, messages, err := validateLocalAppTextTurnRequest(req) + spec, err := localAppTextGenerateSpec(req) if err != nil { return err } - bridge := &localAppTextTurnStreamBridge{ServerStreamingServer: stream} + declared := make(map[string]*runtimev1.ToolSpec, len(spec.GetTools())) + for _, tool := range spec.GetTools() { + declared[tool.GetName()] = tool + } + bridge := &localAppTextTurnStreamBridge{ + ServerStreamingServer: stream, tools: declared, + toolChoice: spec.GetToolChoice(), toolChoiceName: spec.GetToolChoiceName(), + } return s.StreamScenario(&runtimev1.StreamScenarioRequest{ Head: localAppScenarioHead(decision), ScenarioType: runtimev1.ScenarioType_SCENARIO_TYPE_TEXT_GENERATE, ExecutionMode: runtimev1.ExecutionMode_EXECUTION_MODE_STREAM, Spec: &runtimev1.ScenarioSpec{Spec: &runtimev1.ScenarioSpec_TextGenerate{ - TextGenerate: &runtimev1.TextGenerateScenarioSpec{ - Input: messages, - SystemPrompt: systemPrompt, - Temperature: localAppOptionalFloat32(req.Temperature), - TopP: localAppOptionalFloat32(req.TopP), - MaxTokens: localAppOptionalInt32(req.MaxTokens), - TopK: localAppOptionalInt32(req.TopK), - PresencePenalty: localAppOptionalFloat32(req.PresencePenalty), - FrequencyPenalty: localAppOptionalFloat32(req.FrequencyPenalty), - Stop: append([]string(nil), req.GetStop()...), - Seed: localAppOptionalInt64(req.Seed), - }, + TextGenerate: spec, }}, }, bridge) } // localAppTextTurnStreamBridge adapts the owner Scenario event stream to the -// trimmed Local App text-turn event stream. Only typed text deltas and the -// terminal completed or failed event cross this boundary. +// Local App text-turn event stream. A ToolCall is emitted only as a complete +// item; the external host waits for terminal success before executing a batch. type localAppTextTurnStreamBridge struct { grpc.ServerStreamingServer[runtimev1.StreamLocalAppTextTurnEvent] - totalBytes int - sequence uint64 - textCompleted bool + totalBytes int + sequence uint64 + nextItemIndex uint32 + textOpen bool + hasOutput bool + hasTool bool + terminal bool + tools map[string]*runtimev1.ToolSpec + seenCalls map[string]struct{} + toolChoice runtimev1.ToolChoiceMode + toolChoiceName string } func (b *localAppTextTurnStreamBridge) Send(event *runtimev1.StreamScenarioEvent) error { invalid := func() error { return grpcerr.WithReasonCode(codes.Internal, runtimev1.ReasonCode_AI_OUTPUT_INVALID) } - if event == nil { + if event == nil || b.terminal { return invalid() } if !localAppOptionalExactText(event.GetTraceId(), maxLocalAppTraceIDBytes) { @@ -87,15 +91,57 @@ func (b *localAppTextTurnStreamBridge) Send(event *runtimev1.StreamScenarioEvent return nil case *runtimev1.StreamScenarioEvent_Delta: item := payload.Delta.GetTextOutputItem() - if item == nil || item.GetItemIndex() != 0 || b.textCompleted { - // Artifact, reasoning, source, and raw deltas are owner-surface - // shapes; the trimmed text-turn stream fails closed on them. + if item == nil || item.GetItemIndex() != b.nextItemIndex { return invalid() } + if call := item.GetToolCall(); call != nil { + if b.textOpen || !item.GetItemCompleted() { + return invalid() + } + if validateLocalAppTextToolCall(call, b.tools) != nil || !localAppTextOutputChoiceValid(b.toolChoice, b.toolChoiceName, call) { + return localAppTextOutputToolInvalid() + } + if b.seenCalls == nil { + b.seenCalls = make(map[string]struct{}) + } + if _, duplicate := b.seenCalls[call.GetId()]; duplicate { + return invalid() + } + b.totalBytes += proto.Size(call) + if b.totalBytes > maxLocalAppTextTurnTotalBytes { + return invalid() + } + b.seenCalls[call.GetId()] = struct{}{} + b.hasOutput, b.hasTool = true, true + b.nextItemIndex++ + out.Payload = &runtimev1.StreamLocalAppTextTurnEvent_ToolCall{ + ToolCall: &runtimev1.LocalAppTextTurnToolCall{ + ItemIndex: item.GetItemIndex(), ToolCall: proto.Clone(call).(*runtimev1.ToolCall), + }, + } + break + } + if carrier := item.GetReasoningContinuity(); carrier != nil { + if b.textOpen || !item.GetItemCompleted() || !textbehavior.ValidContinuity(carrier) { + return invalid() + } + b.totalBytes += proto.Size(carrier) + if b.totalBytes > maxLocalAppTextTurnTotalBytes { + return invalid() + } + b.nextItemIndex++ + out.Payload = &runtimev1.StreamLocalAppTextTurnEvent_ReasoningContinuity{ + ReasoningContinuity: &runtimev1.LocalAppTextTurnContinuity{ + ItemIndex: item.GetItemIndex(), Carrier: proto.Clone(carrier).(*runtimev1.ReasoningContinuityCarrier), + }, + } + break + } text := item.GetText() if text == nil { - if item.GetItemCompleted() && item.GetDelta() == nil { - b.textCompleted = true + if item.GetItemCompleted() && item.GetDelta() == nil && b.textOpen { + b.textOpen = false + b.nextItemIndex++ return nil } return invalid() @@ -109,18 +155,25 @@ func (b *localAppTextTurnStreamBridge) Send(event *runtimev1.StreamScenarioEvent return invalid() } out.Payload = &runtimev1.StreamLocalAppTextTurnEvent_Delta{ - Delta: &runtimev1.LocalAppTextTurnDelta{Text: text.GetText()}, + Delta: &runtimev1.LocalAppTextTurnDelta{Text: text.GetText(), ItemIndex: item.GetItemIndex()}, } + b.hasOutput = true + b.textOpen = !item.GetItemCompleted() if item.GetItemCompleted() { - b.textCompleted = true + b.nextItemIndex++ } case *runtimev1.StreamScenarioEvent_Completed: - if !b.textCompleted || b.totalBytes == 0 || !localAppTextCandidateFinishReason(payload.Completed.GetFinishReason()) { + if !b.hasOutput || b.textOpen || !localAppTextFinishReason(payload.Completed.GetFinishReason()) || + (payload.Completed.GetFinishReason() == runtimev1.FinishReason_FINISH_REASON_TOOL_CALL && !b.hasTool) { return invalid() } + if localAppTextRequiresTool(b.toolChoice) && !b.hasTool { + return localAppTextOutputToolInvalid() + } out.Payload = &runtimev1.StreamLocalAppTextTurnEvent_Completed{ Completed: &runtimev1.LocalAppTextTurnCompleted{FinishReason: payload.Completed.GetFinishReason()}, } + b.terminal = true case *runtimev1.StreamScenarioEvent_Failed: if payload.Failed.GetReasonCode() == runtimev1.ReasonCode_REASON_CODE_UNSPECIFIED || !localAppOptionalExactText(payload.Failed.GetActionHint(), maxLocalAppTextTurnActionHintBytes) { @@ -134,9 +187,9 @@ func (b *localAppTextTurnStreamBridge) Send(event *runtimev1.StreamScenarioEvent failed.Interruption, _ = proto.Clone(interruption).(*runtimev1.ExecutionInterruption) } out.Payload = &runtimev1.StreamLocalAppTextTurnEvent_Failed{Failed: failed} + b.terminal = true default: - // Tool call, tool result, and tool approval events are owner-surface - // shapes; the trimmed text-turn stream fails closed on them. + // Tool results and approval workflows are never Runtime output. return invalid() } // Owner-only Started and Usage events are intentionally omitted, so the @@ -146,23 +199,6 @@ func (b *localAppTextTurnStreamBridge) Send(event *runtimev1.StreamScenarioEvent return b.ServerStreamingServer.Send(out) } -func validateLocalAppTextTurnRequest(req *runtimev1.StreamLocalAppTextTurnRequest) (string, []*runtimev1.ChatMessage, error) { - if req == nil { - return "", nil, grpcerr.WithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_PROTOCOL_ENVELOPE_INVALID) - } - if req.TopK != nil && req.GetTopK() < 0 || - invalidLocalAppTextTurnScalar(req.PresencePenalty, -2, 2) || - invalidLocalAppTextTurnScalar(req.FrequencyPenalty, -2, 2) { - return "", nil, grpcerr.WithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_PROTOCOL_ENVELOPE_INVALID) - } - for _, value := range req.GetStop() { - if strings.TrimSpace(value) == "" { - return "", nil, grpcerr.WithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_PROTOCOL_ENVELOPE_INVALID) - } - } - return validateLocalAppTextCandidateFields(req.GetMessages(), req.Temperature, req.TopP, req.MaxTokens) -} - func invalidLocalAppTextTurnScalar(value *float32, minValue float32, maxValue float32) bool { return value != nil && (math.IsNaN(float64(*value)) || math.IsInf(float64(*value), 0) || *value < minValue || *value > maxValue) } diff --git a/runtime/internal/services/ai/local_app_text_stream_test.go b/runtime/internal/services/ai/local_app_text_stream_test.go index 7209d360cd..28e4619fd4 100644 --- a/runtime/internal/services/ai/local_app_text_stream_test.go +++ b/runtime/internal/services/ai/local_app_text_stream_test.go @@ -123,9 +123,6 @@ func TestLocalAppTextTurnStreamBridgeFailsClosedOnOwnerOnlyEvents(t *testing.T) }, }}, }}}, - {Payload: &runtimev1.StreamScenarioEvent_Delta{Delta: toolCallOutputDelta(0, &runtimev1.ToolCall{ - Id: "call-1", Name: "tool", ArgumentsJson: `{}`, - })}}, {Payload: &runtimev1.StreamScenarioEvent_Completed{Completed: &runtimev1.ScenarioStreamCompleted{ FinishReason: runtimev1.FinishReason_FINISH_REASON_UNSPECIFIED, }}}, diff --git a/runtime/internal/services/ai/local_embed_execution.go b/runtime/internal/services/ai/local_embed_execution.go index 116b52a14a..ebc2a4c81e 100644 --- a/runtime/internal/services/ai/local_embed_execution.go +++ b/runtime/internal/services/ai/local_embed_execution.go @@ -312,9 +312,13 @@ func executeLocalTextEmbedScenario( if err != nil { return nil, err } + spaceID, err := localEmbeddingSpaceID(effective, result.Vectors) + if err != nil { + return nil, err + } return &runtimev1.ExecuteScenarioResponse{ Output: &runtimev1.ScenarioOutput{Output: &runtimev1.ScenarioOutput_TextEmbed{ - TextEmbed: &runtimev1.TextEmbedOutput{Vectors: result.Vectors}, + TextEmbed: &runtimev1.TextEmbedOutput{Vectors: result.Vectors, SpaceId: spaceID}, }}, FinishReason: runtimev1.FinishReason_FINISH_REASON_STOP, Usage: usage, diff --git a/runtime/internal/services/ai/scenario_cloud_immediate_job_test.go b/runtime/internal/services/ai/scenario_cloud_immediate_job_test.go index c2d6aa285a..f4b8372e4d 100644 --- a/runtime/internal/services/ai/scenario_cloud_immediate_job_test.go +++ b/runtime/internal/services/ai/scenario_cloud_immediate_job_test.go @@ -15,6 +15,7 @@ import ( "github.com/nimiplatform/nimi/runtime/internal/nimillm" "github.com/nimiplatform/nimi/runtime/internal/remoteexecution" "github.com/nimiplatform/nimi/runtime/internal/services/connector" + "github.com/nimiplatform/nimi/runtime/internal/textbehavior" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/structpb" @@ -57,7 +58,7 @@ func (h *deleteConnectorBeforeTextHost) StreamText( record connector.ConnectorRecord, target capabilitydriver.CloudTextTarget, request *capabilitydriver.CloudTextMappedRequest, - onDelta func(string) error, + onDelta func(textbehavior.OrderedDelta) error, audit remoteexecution.TextDispatchAudit, ) (capabilitydriver.CloudTextTransportResponse, error) { if err := h.deleteConnector(); err != nil { @@ -112,7 +113,7 @@ func (h *durableCaptureTextHost) ExecuteText( return capabilitydriver.CloudTextTransportResponse{}, err } return capabilitydriver.CloudTextTransportResponse{ - Text: "sync result", FinishReason: runtimev1.FinishReason_FINISH_REASON_STOP, + Items: []textbehavior.OrderedItem{{Kind: textbehavior.OrderedItemText, Text: "sync result"}}, FinishReason: runtimev1.FinishReason_FINISH_REASON_STOP, }, nil } @@ -121,17 +122,17 @@ func (h *durableCaptureTextHost) StreamText( _ connector.ConnectorRecord, _ capabilitydriver.CloudTextTarget, request *capabilitydriver.CloudTextMappedRequest, - onDelta func(string) error, + onDelta func(textbehavior.OrderedDelta) error, audit remoteexecution.TextDispatchAudit, ) (capabilitydriver.CloudTextTransportResponse, error) { if err := h.assertCapturedBeforeDispatch(audit, request); err != nil { return capabilitydriver.CloudTextTransportResponse{}, err } - if err := onDelta("stream result"); err != nil { + if err := onDelta(textbehavior.OrderedDelta{Kind: textbehavior.OrderedItemText, Text: "stream result", ItemCompleted: true}); err != nil { return capabilitydriver.CloudTextTransportResponse{}, err } return capabilitydriver.CloudTextTransportResponse{ - Streamed: true, FinishReason: runtimev1.FinishReason_FINISH_REASON_STOP, + Items: []textbehavior.OrderedItem{{Kind: textbehavior.OrderedItemText, Text: "stream result"}}, FinishReason: runtimev1.FinishReason_FINISH_REASON_STOP, }, nil } diff --git a/runtime/internal/services/ai/scenario_stream_text.go b/runtime/internal/services/ai/scenario_stream_text.go index 97d698a2bf..38fe6f270d 100644 --- a/runtime/internal/services/ai/scenario_stream_text.go +++ b/runtime/internal/services/ai/scenario_stream_text.go @@ -11,6 +11,7 @@ import ( "github.com/nimiplatform/nimi/runtime/internal/grpcerr" "github.com/nimiplatform/nimi/runtime/internal/nimillm" "github.com/nimiplatform/nimi/runtime/internal/rpcctx" + "github.com/nimiplatform/nimi/runtime/internal/textbehavior" "github.com/nimiplatform/nimi/runtime/internal/usagemetrics" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -315,66 +316,58 @@ func streamTextGenerateScenario(s *Service, req *runtimev1.StreamScenarioRequest } var chunkBuf strings.Builder - textItemOpened := false - sendDelta := func(text string) error { - if text == "" { - return nil - } - chunkBuf.WriteString(text) - if chunkBuf.Len() < minStreamChunkBytes { + var chunkIndex uint32 + flushDelta := func() error { + if chunkBuf.Len() == 0 { return nil } - chunk := chunkBuf.String() + part := chunkBuf.String() chunkBuf.Reset() - textItemOpened = true if err := send(&runtimev1.StreamScenarioEvent{ EventType: runtimev1.StreamEventType_STREAM_EVENT_DELTA, - Payload: &runtimev1.StreamScenarioEvent_Delta{Delta: textOutputDelta(0, chunk, false)}, + Payload: &runtimev1.StreamScenarioEvent_Delta{Delta: orderedTextOutputDelta(textbehavior.OrderedDelta{Kind: textbehavior.OrderedItemText, ItemIndex: chunkIndex, Text: part})}, }); err != nil { return err } recordFirstDeltaSent() return nil } - flushDelta := func() error { - if chunkBuf.Len() == 0 { + requestCtx = nimillm.WithStreamSimulationFlag(requestCtx, &streamSimulated) + result, streamErr := s.streamCapturedCloudText(requestCtx, effective, func(delta textbehavior.OrderedDelta) error { + recordFirstProviderCallback() + recordActivity() + if delta.Kind == textbehavior.OrderedItemText && !delta.ItemCompleted { + if chunkBuf.Len() > 0 && chunkIndex != delta.ItemIndex { + if err := flushDelta(); err != nil { + return err + } + } + chunkIndex = delta.ItemIndex + chunkBuf.WriteString(delta.Text) + if chunkBuf.Len() >= minStreamChunkBytes { + return flushDelta() + } return nil } - chunk := chunkBuf.String() - chunkBuf.Reset() - textItemOpened = true + if err := flushDelta(); err != nil { + return err + } if err := send(&runtimev1.StreamScenarioEvent{ EventType: runtimev1.StreamEventType_STREAM_EVENT_DELTA, - Payload: &runtimev1.StreamScenarioEvent_Delta{Delta: textOutputDelta(0, chunk, false)}, + Payload: &runtimev1.StreamScenarioEvent_Delta{Delta: orderedTextOutputDelta(delta)}, }); err != nil { return err } recordFirstDeltaSent() return nil - } - requestCtx = nimillm.WithStreamSimulationFlag(requestCtx, &streamSimulated) - result, streamErr := s.streamCapturedCloudText(requestCtx, effective, func(part string) error { - recordFirstProviderCallback() - recordActivity() - return sendDelta(part) }) if streamErr != nil { return failAndStop(streamErr) } - usage = result.Usage - finishReason = result.FinishReason - + usage, finishReason = result.Usage, result.FinishReason if err := flushDelta(); err != nil { return err } - if textItemOpened { - if err := send(&runtimev1.StreamScenarioEvent{ - EventType: runtimev1.StreamEventType_STREAM_EVENT_DELTA, - Payload: &runtimev1.StreamScenarioEvent_Delta{Delta: textOutputDelta(0, "", true)}, - }); err != nil { - return err - } - } if streamSimulated { s.recordStreamSimulation( req.GetHead().GetAppId(), diff --git a/runtime/internal/services/ai/scenario_sync_handlers.go b/runtime/internal/services/ai/scenario_sync_handlers.go index 05608959d6..6376b6e289 100644 --- a/runtime/internal/services/ai/scenario_sync_handlers.go +++ b/runtime/internal/services/ai/scenario_sync_handlers.go @@ -87,7 +87,7 @@ func executeTextGenerateScenario(ctx context.Context, s *Service, req *runtimev1 } return &runtimev1.ExecuteScenarioResponse{ Output: &runtimev1.ScenarioOutput{Output: &runtimev1.ScenarioOutput_TextGenerate{ - TextGenerate: canonicalTextGenerateOutput(result.Text, result.ToolCalls), + TextGenerate: canonicalTextGenerateOutputFromOrdered(result.Items), }}, FinishReason: result.FinishReason, Usage: result.Usage, @@ -131,9 +131,13 @@ func executeTextEmbedScenario(ctx context.Context, s *Service, req *runtimev1.Ex if err != nil { return nil, err } + spaceID, err := cloudEmbeddingSpaceID(effective, result.Vectors) + if err != nil { + return nil, err + } return &runtimev1.ExecuteScenarioResponse{ Output: &runtimev1.ScenarioOutput{Output: &runtimev1.ScenarioOutput_TextEmbed{ - TextEmbed: &runtimev1.TextEmbedOutput{Vectors: result.Vectors}, + TextEmbed: &runtimev1.TextEmbedOutput{Vectors: result.Vectors, SpaceId: spaceID}, }}, FinishReason: runtimev1.FinishReason_FINISH_REASON_STOP, Usage: result.Usage, diff --git a/runtime/internal/services/ai/text_behavior_adapter.go b/runtime/internal/services/ai/text_behavior_adapter.go index 817b290819..e6c232c9c8 100644 --- a/runtime/internal/services/ai/text_behavior_adapter.go +++ b/runtime/internal/services/ai/text_behavior_adapter.go @@ -7,7 +7,6 @@ import ( runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" "github.com/nimiplatform/nimi/runtime/internal/grpcerr" - "github.com/nimiplatform/nimi/runtime/internal/nimillm" "github.com/nimiplatform/nimi/runtime/internal/textbehavior" "google.golang.org/grpc/codes" ) @@ -15,7 +14,7 @@ import ( // textBehaviorAdapterRegistration is the one Runtime-private, versioned // adapter contract shared by Cloud and Local text targets. A registration is // exact: neither catalog labels nor execution-time probes participate in -// matching. Production intentionally registers no adapter yet. +// matching. Production registrations are explicit, reviewed target slices. type textBehaviorAdapterRegistration struct { AdapterID string Version string @@ -214,10 +213,10 @@ func resolveTextBehaviorAdapterForFacts( if err != nil { return nil, err } - if !requested.any() { - return nil, nil - } if !validTextBehaviorResolutionFacts(facts) { + if !requested.any() { + return nil, nil + } return nil, textBehaviorUnavailableError() } @@ -230,6 +229,21 @@ func resolveTextBehaviorAdapterForFacts( matches = append(matches, registration) } } + if !requested.any() { + // A declared plain-text combination keeps that exact target on its + // captured hooks. Other targets retain their existing base protocol. + declaresPlainText := false + for _, registration := range matches { + for _, combination := range registration.Support.Combinations { + if !combination.ToolUse && !combination.Reasoning && !combination.StructuredOutput { + declaresPlainText = true + } + } + } + if !declaresPlainText { + return nil, nil + } + } switch len(matches) { case 0: return nil, textBehaviorUnavailableError() @@ -364,7 +378,7 @@ func validTextBehaviorSupport(support textBehaviorSupport) bool { seenCombinationModes := map[string]struct{}{} coveredTool, coveredReasoning, coveredStructured := false, false, false for _, combination := range support.Combinations { - if !combination.ToolUse && !combination.Reasoning && !combination.StructuredOutput || len(combination.Modes) == 0 || + if len(combination.Modes) == 0 || combination.ToolUse && support.ToolUse == nil || combination.Reasoning && support.Reasoning == nil || combination.StructuredOutput && support.StructuredOutput == nil { return false @@ -430,6 +444,12 @@ func validTextBehaviorToolUseSupport(support textBehaviorToolUseSupport) bool { } func validTextBehaviorReasoningSupport(support textBehaviorReasoningSupport) bool { + // A stateless relay can preserve opaque continuity without admitting any + // reasoning activation, intensity or presentation controls. + if support.OpaqueContinuityCarrier && !support.SummaryTranscript && len(support.Activations) == 0 && + len(support.Presentations) == 0 && len(support.Efforts) == 0 && !support.ExactBudget { + return true + } if len(support.Activations) == 0 || len(support.Presentations) == 0 || len(support.Efforts) == 0 && !support.ExactBudget { return false } @@ -864,22 +884,6 @@ func textBehaviorUnavailableError() error { return grpcerr.WithReasonCode(codes.InvalidArgument, runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED) } -func (adapter *resolvedTextBehaviorAdapter) nimillmAdmission() *nimillm.TextBehaviorAdmission { - if adapter == nil || adapter.facts.CloudTarget == nil { - return nil - } - return &nimillm.TextBehaviorAdmission{ - AdapterID: adapter.registration.AdapterID, Version: adapter.registration.Version, - Provider: adapter.facts.CloudTarget.Provider, ProviderModelID: adapter.facts.CloudTarget.ProviderModelID, - ToolUse: adapter.requested.toolUse, Reasoning: adapter.requested.reasoning, - StructuredOutput: adapter.requested.structured, - Sync: adapter.mode == runtimev1.ExecutionMode_EXECUTION_MODE_SYNC, - Stream: adapter.mode == runtimev1.ExecutionMode_EXECUTION_MODE_STREAM, - Async: adapter.mode == runtimev1.ExecutionMode_EXECUTION_MODE_ASYNC_JOB, - ToolStructuredCombination: adapter.requested.toolUse && adapter.requested.structured, - } -} - func (adapter *resolvedTextBehaviorAdapter) capture() *textBehaviorAdapterCapture { if adapter == nil { return nil diff --git a/runtime/internal/services/ai/text_behavior_adapter_anthropic.go b/runtime/internal/services/ai/text_behavior_adapter_anthropic.go new file mode 100644 index 0000000000..13ac76c68b --- /dev/null +++ b/runtime/internal/services/ai/text_behavior_adapter_anthropic.go @@ -0,0 +1,29 @@ +package ai + +import ( + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "github.com/nimiplatform/nimi/runtime/internal/capabilitydriver" +) + +// @nimi-authority: rule.nimi.runtime.ai-provider.anthropic-sonnet46-text-behaviors +func anthropicSonnet46TextBehaviorRegistration() textBehaviorAdapterRegistration { + modes := []runtimev1.ExecutionMode{runtimev1.ExecutionMode_EXECUTION_MODE_SYNC, runtimev1.ExecutionMode_EXECUTION_MODE_STREAM} + return textBehaviorAdapterRegistration{ + AdapterID: "anthropic.sonnet46.messages", Version: "1", + ImplementationID: "anthropic", DriverID: "nimillm", DriverDialect: "anthropic", + CloudTarget: &textBehaviorCloudTarget{Provider: "anthropic", ProviderModelID: "claude-sonnet-4-6"}, + Support: textBehaviorSupport{ + ToolUse: &textBehaviorToolUseSupport{ + SpecKinds: []runtimev1.ToolSpecKind{runtimev1.ToolSpecKind_TOOL_SPEC_KIND_FUNCTION}, + ChoiceModes: []runtimev1.ToolChoiceMode{runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_AUTO, runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_NONE, runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_REQUIRED, runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_TOOL}, + SingleCall: true, MultipleCalls: true, ParallelCalls: true, ToolOnlyResponse: true, MixedTextAndCall: true, ToolResultRoundTrip: true, + }, + StructuredOutput: &textBehaviorStructuredOutputSupport{Kinds: []runtimev1.ResponseFormatKind{runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_SCHEMA}, SupportsStrictJSONSchema: true}, + Combinations: []textBehaviorCombination{{ToolUse: true, Modes: modes}, {StructuredOutput: true, Modes: modes}}, + }, + ExecutionSemantics: textBehaviorExecutionSemantics{ProcessIdentityImpact: textBehaviorProcessIdentityUnaffected}, + RequestSerializerID: "anthropic/messages/request/v1", RequestSerializer: capabilitydriver.AnthropicTextBehaviorRequestSerializer, + NonStreamParserID: "anthropic/messages/nonstream/v1", NonStreamParser: capabilitydriver.AnthropicTextBehaviorNonStreamParser, + StreamAssemblerID: "anthropic/messages/stream/v1", StreamAssembler: capabilitydriver.AnthropicTextBehaviorStreamAssembler, + } +} diff --git a/runtime/internal/services/ai/text_behavior_adapter_codex.go b/runtime/internal/services/ai/text_behavior_adapter_codex.go new file mode 100644 index 0000000000..956af9f9c9 --- /dev/null +++ b/runtime/internal/services/ai/text_behavior_adapter_codex.go @@ -0,0 +1,34 @@ +package ai + +import ( + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + "github.com/nimiplatform/nimi/runtime/internal/capabilitydriver" +) + +// @nimi-authority: rule.nimi.runtime.ai-provider.codex-text-behaviors +func codexTextBehaviorRegistration(modelID, adapterID string) textBehaviorAdapterRegistration { + modes := []runtimev1.ExecutionMode{runtimev1.ExecutionMode_EXECUTION_MODE_SYNC, runtimev1.ExecutionMode_EXECUTION_MODE_STREAM} + return textBehaviorAdapterRegistration{ + AdapterID: adapterID, Version: "1", + ImplementationID: "openai_codex", DriverID: "nimillm", DriverDialect: "openai_codex", + CloudTarget: &textBehaviorCloudTarget{Provider: "openai_codex", ProviderModelID: modelID}, + Support: textBehaviorSupport{ + ToolUse: &textBehaviorToolUseSupport{ + SpecKinds: []runtimev1.ToolSpecKind{runtimev1.ToolSpecKind_TOOL_SPEC_KIND_FUNCTION}, + ChoiceModes: []runtimev1.ToolChoiceMode{runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_AUTO, runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_NONE, runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_REQUIRED, runtimev1.ToolChoiceMode_TOOL_CHOICE_MODE_TOOL}, + SingleCall: true, MultipleCalls: true, ParallelCalls: true, ToolOnlyResponse: true, MixedTextAndCall: true, ToolResultRoundTrip: true, + }, + Reasoning: &textBehaviorReasoningSupport{OpaqueContinuityCarrier: true}, + StructuredOutput: &textBehaviorStructuredOutputSupport{Kinds: []runtimev1.ResponseFormatKind{runtimev1.ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_SCHEMA}, SupportsStrictJSONSchema: true}, + Combinations: []textBehaviorCombination{ + {Modes: modes}, + {ToolUse: true, Modes: modes}, {StructuredOutput: true, Modes: modes}, {Reasoning: true, Modes: modes}, + {ToolUse: true, Reasoning: true, Modes: modes}, {StructuredOutput: true, Reasoning: true, Modes: modes}, + }, + }, + ExecutionSemantics: textBehaviorExecutionSemantics{ProcessIdentityImpact: textBehaviorProcessIdentityUnaffected}, + RequestSerializerID: "openai_codex/responses/request/v1", RequestSerializer: capabilitydriver.CodexTextBehaviorRequestSerializer, + NonStreamParserID: "openai_codex/responses/nonstream/v1", NonStreamParser: capabilitydriver.CodexTextBehaviorNonStreamParser, + StreamAssemblerID: "openai_codex/responses/stream/v1", StreamAssembler: capabilitydriver.CodexTextBehaviorStreamAssembler, + } +} diff --git a/runtime/internal/services/ai/text_behavior_adapter_gemma4.go b/runtime/internal/services/ai/text_behavior_adapter_gemma4.go index 20e2da1f06..18201d5e5f 100644 --- a/runtime/internal/services/ai/text_behavior_adapter_gemma4.go +++ b/runtime/internal/services/ai/text_behavior_adapter_gemma4.go @@ -21,6 +21,11 @@ func productionTextBehaviorAdapterRegistrations() []textBehaviorAdapterRegistrat for _, entry := range cohort { registrations = append(registrations, gemma4TextBehaviorRegistration(entry)) } + registrations = append(registrations, anthropicSonnet46TextBehaviorRegistration()) + registrations = append(registrations, + codexTextBehaviorRegistration("gpt-5.6-sol", "openai_codex.sol.responses"), + codexTextBehaviorRegistration("gpt-6-astra", "openai_codex.astra.responses"), + ) return registrations } diff --git a/runtime/internal/services/ai/text_behavior_adapter_test.go b/runtime/internal/services/ai/text_behavior_adapter_test.go index 44fc2b68a2..3e2a011192 100644 --- a/runtime/internal/services/ai/text_behavior_adapter_test.go +++ b/runtime/internal/services/ai/text_behavior_adapter_test.go @@ -14,11 +14,24 @@ import ( func TestTextBehaviorAdapterResolutionIsExactAndClosed(t *testing.T) { registrations := productionTextBehaviorAdapterRegistrations() - if len(registrations) != 9 { - t.Fatalf("production Gemma 4 adapter registrations = %d, want 9", len(registrations)) + if len(registrations) != 12 { + t.Fatalf("production adapter registrations = %d, want nine Gemma mappings and three Cloud targets", len(registrations)) + } + expectedCloudTargets := map[string]string{ + "anthropic/claude-sonnet-4-6": "anthropic.sonnet46.messages", + "openai_codex/gpt-5.6-sol": "openai_codex.sol.responses", + "openai_codex/gpt-6-astra": "openai_codex.astra.responses", } seenContents := map[string]struct{}{} for _, registration := range registrations { + if registration.CloudTarget != nil { + key := registration.CloudTarget.Provider + "/" + registration.CloudTarget.ProviderModelID + if !validTextBehaviorAdapterRegistration(registration) || expectedCloudTargets[key] != registration.AdapterID { + t.Fatalf("unexpected Cloud target: %+v", registration) + } + delete(expectedCloudTargets, key) + continue + } if !validTextBehaviorAdapterRegistration(registration) || registration.LocalTarget == nil || registration.DriverDialect != gemma4TextDriverDialect || registration.LocalTarget.RecipeID != gemma4TextRecipeID || len(registration.LocalTarget.ModelContents) != 1 { @@ -30,6 +43,9 @@ func TestTextBehaviorAdapterResolutionIsExactAndClosed(t *testing.T) { } seenContents[contentID] = struct{}{} } + if len(expectedCloudTargets) != 0 { + t.Fatalf("missing Cloud targets: %v", expectedCloudTargets) + } oldFacts := textBehaviorAdapterResolutionFacts{ ImplementationID: "local.text.generate.llama-cpp", DriverID: "nimi.runtime.driver.llama-cpp", DriverDialect: "llama.cpp/text-generate/v1", @@ -77,6 +93,28 @@ func TestTextBehaviorAdapterResolutionIsExactAndClosed(t *testing.T) { } } +func TestCodexPlainTextUsesExactDeclaredHooks(t *testing.T) { + registrations := productionTextBehaviorAdapterRegistrations() + identity := &runtimev1.CapabilityImplementationIdentity{ImplementationId: "openai_codex", DriverId: "nimillm", DriverDialect: "openai_codex"} + plain := &runtimev1.TextGenerateScenarioSpec{Input: []*runtimev1.ChatMessage{{Role: "user", Content: "hello"}}} + for _, model := range []string{"gpt-5.6-sol", "gpt-6-astra"} { + for _, mode := range []runtimev1.ExecutionMode{runtimev1.ExecutionMode_EXECUTION_MODE_SYNC, runtimev1.ExecutionMode_EXECUTION_MODE_STREAM} { + adapter, err := resolveTextBehaviorAdapter(registrations, identity, "openai_codex", model, mode, plain) + if err != nil || adapter == nil || adapter.registration.CloudTarget.ProviderModelID != model { + t.Fatalf("plain %s/%s did not capture exact hooks: %+v %v", model, mode, adapter, err) + } + } + } + if _, err := resolveTextBehaviorAdapter(registrations, identity, "openai_codex", "gpt-6-astra-preview", runtimev1.ExecutionMode_EXECUTION_MODE_SYNC, testTextBehaviorToolSpec()); textBehaviorReason(err) != runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_UNSUPPORTED { + t.Fatalf("unregistered model matched by name: %v", err) + } + duplicate := codexTextBehaviorRegistration("gpt-6-astra", "conflicting-astra-adapter") + registrations = append(registrations, duplicate) + if _, err := resolveTextBehaviorAdapter(registrations, identity, "openai_codex", "gpt-6-astra", runtimev1.ExecutionMode_EXECUTION_MODE_SYNC, plain); textBehaviorReason(err) != runtimev1.ReasonCode_AI_TEXT_BEHAVIOR_AMBIGUOUS { + t.Fatalf("ambiguous plain-text hooks accepted: %v", err) + } +} + func TestTextBehaviorAdapterCombinationRequiresExactDeclaration(t *testing.T) { identity := &runtimev1.CapabilityImplementationIdentity{ImplementationId: "cloud.text.openai", DriverId: "nimi.runtime.driver.openai", DriverDialect: "openai-chat/v1"} registration := testCloudToolAdapter(identity, "openai-tools-json", "model-a") diff --git a/runtime/internal/textbehavior/schema.go b/runtime/internal/textbehavior/schema.go new file mode 100644 index 0000000000..ad94ac3e1c --- /dev/null +++ b/runtime/internal/textbehavior/schema.go @@ -0,0 +1,56 @@ +package textbehavior + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + runtimev1 "github.com/nimiplatform/nimi/runtime/gen/runtime/v1" + jsonschema "github.com/santhosh-tekuri/jsonschema/v6" +) + +const schemaResource = "urn:nimi:text-behavior:schema" + +type noNetworkSchemaLoader struct{} + +func (noNetworkSchemaLoader) Load(url string) (any, error) { + return nil, fmt.Errorf("remote JSON Schema resource %q is unavailable", url) +} + +// CompileJSONSchema shares the same self-contained schema boundary between +// text adapters and the protected App request boundary. +// @nimi-authority: rule.nimi.runtime.ai-provider.r123 +func CompileJSONSchema(document map[string]any) (*jsonschema.Schema, error) { + compiler := jsonschema.NewCompiler() + compiler.UseLoader(noNetworkSchemaLoader{}) + if err := compiler.AddResource(schemaResource, document); err != nil { + return nil, err + } + return compiler.Compile(schemaResource) +} + +// @nimi-authority: rule.nimi.runtime.ai-provider.r119 +func ValidateToolArguments(tool *runtimev1.ToolSpec, arguments string) error { + decoder := json.NewDecoder(strings.NewReader(arguments)) + decoder.UseNumber() + var instance any + if err := decoder.Decode(&instance); err != nil { + return fmt.Errorf("decode tool arguments: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return fmt.Errorf("tool arguments must contain one JSON object") + } + if _, ok := instance.(map[string]any); !ok { + return fmt.Errorf("tool arguments must be a JSON object") + } + if tool == nil || tool.GetInputSchema() == nil { + return nil + } + schema, err := CompileJSONSchema(tool.GetInputSchema().AsMap()) + if err != nil { + return fmt.Errorf("compile tool schema: %w", err) + } + return schema.Validate(instance) +} diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 50dd511ab5..2bb2d176ea 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -16,5 +16,5 @@ - Skip generated outputs and unrelated reports when debugging a script. ## Verification Commands -- Script-sensitive gates: `pnpm check:runtime-catalog-drift`, `pnpm proto:drift-check`, and `pnpm spec:authority:check` when authority inputs are affected. +- Select the gate for the changed input or pipeline: `pnpm check:runtime-catalog-drift` for Runtime catalog projections, `pnpm proto:drift-check` for proto generation, and `pnpm spec:authority:check` for authority inputs or their checker integration. - When changing script inputs, rerun the matching `generate:*` command and then the paired `check:*` command. diff --git a/sdks/AGENTS.md b/sdks/AGENTS.md index c915f6ef80..ee5b589328 100644 --- a/sdks/AGENTS.md +++ b/sdks/AGENTS.md @@ -12,11 +12,10 @@ - Adapter public surfaces live in adapter packages, not base SDK shims. - Unsupported capability fails closed. No pseudo-success or hidden bypass. ## Retrieval Defaults -- Start in `sdks/typescript`, `sdks/generators`, `sdks/conformance`, and `.nimi/spec/sdks`. +- Start in the affected SDK implementation and its direct test or consumer. Read the relevant generator for generated changes, conformance code for shared language contracts, and exact authority units when semantics or ownership are unresolved. - For generated drift, inspect proto/OpenAPI input before generated output. - Skip `archive/**`, `**/dist/**`, `**/generated/**`, and dependency folders. ## Verification Commands -- `node sdks/generators/generate.mjs --check` -- `node sdks/conformance/run.mjs --language all --profile typed-core` -- `pnpm --filter @nimiplatform/sdk build` -- `pnpm --filter @nimiplatform/sdk test` +- Handwritten TypeScript changes: run the directly affected tests and `pnpm --filter @nimiplatform/sdk build`; run `pnpm --filter @nimiplatform/sdk test` for shared behavior or public API changes. +- Generator or proto/OpenAPI input changes: run `node sdks/generators/generate.mjs --check` after regenerating affected output. +- Shared typed-core or cross-language contract changes: run `node sdks/conformance/run.mjs --language all --profile typed-core`. diff --git a/sdks/README.md b/sdks/README.md index 09a6607ec2..0c995c8c5a 100644 --- a/sdks/README.md +++ b/sdks/README.md @@ -41,6 +41,57 @@ The conformance runner validates generated manifest parity and generated Runtime/Realm core behavior. TypeScript handwritten surfaces have their own package tests and SDK matrix gates. +## Local App text tools + +SDK 0.12.0 exposes `createNimiLocalAppTextModel` from `@nimiplatform/sdk/ai`. +It binds the common `NimiAiModel` interface to a protected App client's `ai` +surface. It supports function tools, ordered assistant output and tool results, +tool choice, and a structured `responseFormat`. The App's AIConfig selects a +compatible execution configuration; requests contain no provider or model +identity. Kit 0.8.0 and the matching Runtime contract are required. + +```ts +import { createNimiLocalAppTextModel } from '@nimiplatform/sdk/ai'; +import type { NimiLocalAppClient } from '@nimiplatform/sdk/app'; + +export function requestSearch(client: NimiLocalAppClient, signal: AbortSignal) { + return createNimiLocalAppTextModel(client.ai).generateText({ + messages: [{ role: 'user', content: [{ type: 'text', text: 'Find sources about trees.' }] }], + tools: [{ + name: 'search', + description: 'Search for sources.', + inputSchema: { + type: 'object', properties: { query: { type: 'string' } }, + required: ['query'], additionalProperties: false, + }, + }], + toolChoice: 'required', + signal, + }); +} +``` + +The result contains complete tool calls. The caller executes its fixed handlers +after a successful step, preserves `outputItems` in ordered `turnItems`, appends +the corresponding `tool-result` entries, and explicitly requests the next step. +This includes opaque `reasoning-continuity` items required by exact adapters: +retain their order and bytes without decoding or displaying them. Update custom +stream collectors to handle that event as well as `tool-call`; the standard +model collector already preserves it. Carriers use `Uint8Array` in the common +model and byte arrays at the JSON shell boundary, with a 64 KiB payload limit. +`generateText` collects the same cancellable stream as `streamText`; neither +method owns a multi-step workflow. Missing terminal events, invalid tool calls +and interrupted streams fail without manufacturing a result. + +Direct App callers can use `ai.scenario.execute({ type: 'text-generate', ... })` +or `ai.text.streamTurn(...)`. Custom Host carriers can reuse the public text +input/output validators and feature-client constructors from +`@nimiplatform/sdk/app`. Migrate exhaustive stream matches for `tool-call`, +`tool-calls` and the required delta `itemIndex`; the text-candidate input stays +narrow. Input is limited to 128 messages, 64 function tools and 1 MiB, with a +256 KiB output bound. Partial structured text is display-only until a successful +terminal result passes the caller's declared response schema. + ## Locate objects in a local image An App with `runtime.consume` can upload an image and run `vision.locate` diff --git a/sdks/generators/shared/generated/runtime-core.manifest.json b/sdks/generators/shared/generated/runtime-core.manifest.json index f218207ae2..946e551f5f 100644 --- a/sdks/generators/shared/generated/runtime-core.manifest.json +++ b/sdks/generators/shared/generated/runtime-core.manifest.json @@ -6993,9 +6993,12 @@ "LocalAppTextCandidateMessage", "LocalAppTextEmbedOutput", "LocalAppTextEmbedScenarioSpec", + "LocalAppTextGenerateOutput", "LocalAppTextTurnCompleted", + "LocalAppTextTurnContinuity", "LocalAppTextTurnDelta", "LocalAppTextTurnFailed", + "LocalAppTextTurnToolCall", "LocalAppVideoGenerateJobSpec", "LocalAppVideoGenerationOptions", "LocalAppVoiceAsset", @@ -16740,6 +16743,13 @@ "repeated": false, "optional": false, "number": 2 + }, + { + "name": "text_generate", + "type": "StreamLocalAppTextTurnRequest", + "repeated": false, + "optional": false, + "number": 3 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -16767,6 +16777,13 @@ "repeated": false, "optional": false, "number": 3 + }, + { + "name": "text_generate", + "type": "LocalAppTextGenerateOutput", + "repeated": false, + "optional": false, + "number": 4 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -24964,6 +24981,13 @@ "repeated": false, "optional": false, "number": 2 + }, + { + "name": "turn_items", + "type": "TextTurnItem", + "repeated": true, + "optional": false, + "number": 3 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -24977,6 +25001,13 @@ "repeated": true, "optional": false, "number": 1 + }, + { + "name": "space_id", + "type": "string", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -24994,6 +25025,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextGenerateOutput", + "fields": [ + { + "name": "items", + "type": "TextOutputItem", + "repeated": true, + "optional": false, + "number": 1 + }, + { + "name": "finish_reason", + "type": "FinishReason", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppTextTurnCompleted", "fields": [ @@ -25007,6 +25058,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextTurnContinuity", + "fields": [ + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 1 + }, + { + "name": "carrier", + "type": "ReasoningContinuityCarrier", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppTextTurnDelta", "fields": [ @@ -25016,6 +25087,13 @@ "repeated": false, "optional": false, "number": 1 + }, + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -25047,6 +25125,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextTurnToolCall", + "fields": [ + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 1 + }, + { + "name": "tool_call", + "type": "ToolCall", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppVideoGenerateJobSpec", "fields": [ @@ -34152,6 +34250,20 @@ "repeated": false, "optional": false, "number": 5 + }, + { + "name": "tool_call", + "type": "LocalAppTextTurnToolCall", + "repeated": false, + "optional": false, + "number": 6 + }, + { + "name": "reasoning_continuity", + "type": "LocalAppTextTurnContinuity", + "repeated": false, + "optional": false, + "number": 7 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -34221,6 +34333,34 @@ "repeated": false, "optional": true, "number": 9 + }, + { + "name": "tools", + "type": "ToolSpec", + "repeated": true, + "optional": false, + "number": 10 + }, + { + "name": "tool_choice", + "type": "ToolChoiceMode", + "repeated": false, + "optional": false, + "number": 11 + }, + { + "name": "tool_choice_name", + "type": "string", + "repeated": false, + "optional": false, + "number": 12 + }, + { + "name": "response_format", + "type": "ResponseFormat", + "repeated": false, + "optional": false, + "number": 13 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -35171,6 +35311,13 @@ "repeated": true, "optional": false, "number": 1 + }, + { + "name": "space_id", + "type": "string", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" diff --git a/sdks/go/coregenerated/runtime-core.manifest.json b/sdks/go/coregenerated/runtime-core.manifest.json index d4259b2623..d71b1192a2 100644 --- a/sdks/go/coregenerated/runtime-core.manifest.json +++ b/sdks/go/coregenerated/runtime-core.manifest.json @@ -6993,9 +6993,12 @@ "LocalAppTextCandidateMessage", "LocalAppTextEmbedOutput", "LocalAppTextEmbedScenarioSpec", + "LocalAppTextGenerateOutput", "LocalAppTextTurnCompleted", + "LocalAppTextTurnContinuity", "LocalAppTextTurnDelta", "LocalAppTextTurnFailed", + "LocalAppTextTurnToolCall", "LocalAppVideoGenerateJobSpec", "LocalAppVideoGenerationOptions", "LocalAppVoiceAsset", @@ -16740,6 +16743,13 @@ "repeated": false, "optional": false, "number": 2 + }, + { + "name": "text_generate", + "type": "StreamLocalAppTextTurnRequest", + "repeated": false, + "optional": false, + "number": 3 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -16767,6 +16777,13 @@ "repeated": false, "optional": false, "number": 3 + }, + { + "name": "text_generate", + "type": "LocalAppTextGenerateOutput", + "repeated": false, + "optional": false, + "number": 4 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -24964,6 +24981,13 @@ "repeated": false, "optional": false, "number": 2 + }, + { + "name": "turn_items", + "type": "TextTurnItem", + "repeated": true, + "optional": false, + "number": 3 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -24977,6 +25001,13 @@ "repeated": true, "optional": false, "number": 1 + }, + { + "name": "space_id", + "type": "string", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -24994,6 +25025,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextGenerateOutput", + "fields": [ + { + "name": "items", + "type": "TextOutputItem", + "repeated": true, + "optional": false, + "number": 1 + }, + { + "name": "finish_reason", + "type": "FinishReason", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppTextTurnCompleted", "fields": [ @@ -25007,6 +25058,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextTurnContinuity", + "fields": [ + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 1 + }, + { + "name": "carrier", + "type": "ReasoningContinuityCarrier", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppTextTurnDelta", "fields": [ @@ -25016,6 +25087,13 @@ "repeated": false, "optional": false, "number": 1 + }, + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -25047,6 +25125,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextTurnToolCall", + "fields": [ + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 1 + }, + { + "name": "tool_call", + "type": "ToolCall", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppVideoGenerateJobSpec", "fields": [ @@ -34152,6 +34250,20 @@ "repeated": false, "optional": false, "number": 5 + }, + { + "name": "tool_call", + "type": "LocalAppTextTurnToolCall", + "repeated": false, + "optional": false, + "number": 6 + }, + { + "name": "reasoning_continuity", + "type": "LocalAppTextTurnContinuity", + "repeated": false, + "optional": false, + "number": 7 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -34221,6 +34333,34 @@ "repeated": false, "optional": true, "number": 9 + }, + { + "name": "tools", + "type": "ToolSpec", + "repeated": true, + "optional": false, + "number": 10 + }, + { + "name": "tool_choice", + "type": "ToolChoiceMode", + "repeated": false, + "optional": false, + "number": 11 + }, + { + "name": "tool_choice_name", + "type": "string", + "repeated": false, + "optional": false, + "number": 12 + }, + { + "name": "response_format", + "type": "ResponseFormat", + "repeated": false, + "optional": false, + "number": 13 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -35171,6 +35311,13 @@ "repeated": true, "optional": false, "number": 1 + }, + { + "name": "space_id", + "type": "string", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" diff --git a/sdks/go/coregenerated/typed_clients.go b/sdks/go/coregenerated/typed_clients.go index 30d40c5ad7..a1038aa3ce 100644 --- a/sdks/go/coregenerated/typed_clients.go +++ b/sdks/go/coregenerated/typed_clients.go @@ -3830,12 +3830,14 @@ type ErrorInfo struct { type ExecuteLocalAppScenarioRequest struct { TextEmbed *LocalAppTextEmbedScenarioSpec `json:"text_embed,omitempty"` ImageGenerate *LocalAppImageGenerateScenarioSpec `json:"image_generate,omitempty"` + TextGenerate *StreamLocalAppTextTurnRequest `json:"text_generate,omitempty"` } type ExecuteLocalAppScenarioResponse struct { TextEmbed *LocalAppTextEmbedOutput `json:"text_embed,omitempty"` ImageGenerate *LocalAppImageGenerateOutput `json:"image_generate,omitempty"` TraceId string `json:"trace_id,omitempty"` + TextGenerate *LocalAppTextGenerateOutput `json:"text_generate,omitempty"` } type ExecuteScenarioRequest struct { @@ -5599,24 +5601,37 @@ type LocalAppSpeechTranscribeJobSpec struct { } type LocalAppTextCandidateMessage struct { - Role string `json:"role,omitempty"` - Text string `json:"text,omitempty"` + Role string `json:"role,omitempty"` + Text string `json:"text,omitempty"` + TurnItems []TextTurnItem `json:"turn_items,omitempty"` } type LocalAppTextEmbedOutput struct { Vectors []EmbeddingVector `json:"vectors,omitempty"` + SpaceId string `json:"space_id,omitempty"` } type LocalAppTextEmbedScenarioSpec struct { Inputs []string `json:"inputs,omitempty"` } +type LocalAppTextGenerateOutput struct { + Items []TextOutputItem `json:"items,omitempty"` + FinishReason FinishReason `json:"finish_reason,omitempty"` +} + type LocalAppTextTurnCompleted struct { FinishReason FinishReason `json:"finish_reason,omitempty"` } +type LocalAppTextTurnContinuity struct { + ItemIndex uint32 `json:"item_index,omitempty"` + Carrier *ReasoningContinuityCarrier `json:"carrier,omitempty"` +} + type LocalAppTextTurnDelta struct { - Text string `json:"text,omitempty"` + Text string `json:"text,omitempty"` + ItemIndex uint32 `json:"item_index,omitempty"` } type LocalAppTextTurnFailed struct { @@ -5625,6 +5640,11 @@ type LocalAppTextTurnFailed struct { Interruption *ExecutionInterruption `json:"interruption,omitempty"` } +type LocalAppTextTurnToolCall struct { + ItemIndex uint32 `json:"item_index,omitempty"` + ToolCall *ToolCall `json:"tool_call,omitempty"` +} + type LocalAppVideoGenerateJobSpec struct { Prompt string `json:"prompt,omitempty"` NegativePrompt string `json:"negative_prompt,omitempty"` @@ -7462,11 +7482,13 @@ type StatLocalAppAssetResponse struct { } type StreamLocalAppTextTurnEvent struct { - Sequence uint64 `json:"sequence,omitempty"` - TraceId string `json:"trace_id,omitempty"` - Delta *LocalAppTextTurnDelta `json:"delta,omitempty"` - Completed *LocalAppTextTurnCompleted `json:"completed,omitempty"` - Failed *LocalAppTextTurnFailed `json:"failed,omitempty"` + Sequence uint64 `json:"sequence,omitempty"` + TraceId string `json:"trace_id,omitempty"` + Delta *LocalAppTextTurnDelta `json:"delta,omitempty"` + Completed *LocalAppTextTurnCompleted `json:"completed,omitempty"` + Failed *LocalAppTextTurnFailed `json:"failed,omitempty"` + ToolCall *LocalAppTextTurnToolCall `json:"tool_call,omitempty"` + ReasoningContinuity *LocalAppTextTurnContinuity `json:"reasoning_continuity,omitempty"` } type StreamLocalAppTextTurnRequest struct { @@ -7479,6 +7501,10 @@ type StreamLocalAppTextTurnRequest struct { FrequencyPenalty *float32 `json:"frequency_penalty,omitempty"` Stop []string `json:"stop,omitempty"` Seed *int64 `json:"seed,omitempty"` + Tools []ToolSpec `json:"tools,omitempty"` + ToolChoice ToolChoiceMode `json:"tool_choice,omitempty"` + ToolChoiceName string `json:"tool_choice_name,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` } type StreamScenarioEvent struct { @@ -7679,6 +7705,7 @@ type TextBehaviorCapabilityProjection struct { type TextEmbedOutput struct { Vectors []EmbeddingVector `json:"vectors,omitempty"` + SpaceId string `json:"space_id,omitempty"` } type TextEmbedScenarioSpec struct { diff --git a/sdks/python/core_generated/runtime-core.manifest.json b/sdks/python/core_generated/runtime-core.manifest.json index d3a4f4883c..44c8278d18 100644 --- a/sdks/python/core_generated/runtime-core.manifest.json +++ b/sdks/python/core_generated/runtime-core.manifest.json @@ -6993,9 +6993,12 @@ "LocalAppTextCandidateMessage", "LocalAppTextEmbedOutput", "LocalAppTextEmbedScenarioSpec", + "LocalAppTextGenerateOutput", "LocalAppTextTurnCompleted", + "LocalAppTextTurnContinuity", "LocalAppTextTurnDelta", "LocalAppTextTurnFailed", + "LocalAppTextTurnToolCall", "LocalAppVideoGenerateJobSpec", "LocalAppVideoGenerationOptions", "LocalAppVoiceAsset", @@ -16740,6 +16743,13 @@ "repeated": false, "optional": false, "number": 2 + }, + { + "name": "text_generate", + "type": "StreamLocalAppTextTurnRequest", + "repeated": false, + "optional": false, + "number": 3 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -16767,6 +16777,13 @@ "repeated": false, "optional": false, "number": 3 + }, + { + "name": "text_generate", + "type": "LocalAppTextGenerateOutput", + "repeated": false, + "optional": false, + "number": 4 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -24964,6 +24981,13 @@ "repeated": false, "optional": false, "number": 2 + }, + { + "name": "turn_items", + "type": "TextTurnItem", + "repeated": true, + "optional": false, + "number": 3 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -24977,6 +25001,13 @@ "repeated": true, "optional": false, "number": 1 + }, + { + "name": "space_id", + "type": "string", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -24994,6 +25025,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextGenerateOutput", + "fields": [ + { + "name": "items", + "type": "TextOutputItem", + "repeated": true, + "optional": false, + "number": 1 + }, + { + "name": "finish_reason", + "type": "FinishReason", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppTextTurnCompleted", "fields": [ @@ -25007,6 +25058,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextTurnContinuity", + "fields": [ + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 1 + }, + { + "name": "carrier", + "type": "ReasoningContinuityCarrier", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppTextTurnDelta", "fields": [ @@ -25016,6 +25087,13 @@ "repeated": false, "optional": false, "number": 1 + }, + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -25047,6 +25125,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextTurnToolCall", + "fields": [ + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 1 + }, + { + "name": "tool_call", + "type": "ToolCall", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppVideoGenerateJobSpec", "fields": [ @@ -34152,6 +34250,20 @@ "repeated": false, "optional": false, "number": 5 + }, + { + "name": "tool_call", + "type": "LocalAppTextTurnToolCall", + "repeated": false, + "optional": false, + "number": 6 + }, + { + "name": "reasoning_continuity", + "type": "LocalAppTextTurnContinuity", + "repeated": false, + "optional": false, + "number": 7 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -34221,6 +34333,34 @@ "repeated": false, "optional": true, "number": 9 + }, + { + "name": "tools", + "type": "ToolSpec", + "repeated": true, + "optional": false, + "number": 10 + }, + { + "name": "tool_choice", + "type": "ToolChoiceMode", + "repeated": false, + "optional": false, + "number": 11 + }, + { + "name": "tool_choice_name", + "type": "string", + "repeated": false, + "optional": false, + "number": 12 + }, + { + "name": "response_format", + "type": "ResponseFormat", + "repeated": false, + "optional": false, + "number": 13 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -35171,6 +35311,13 @@ "repeated": true, "optional": false, "number": 1 + }, + { + "name": "space_id", + "type": "string", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" diff --git a/sdks/python/core_generated/runtime_typed_client.py b/sdks/python/core_generated/runtime_typed_client.py index f88c597d74..f6578793f0 100644 --- a/sdks/python/core_generated/runtime_typed_client.py +++ b/sdks/python/core_generated/runtime_typed_client.py @@ -2073,12 +2073,14 @@ class ErrorInfo: class ExecuteLocalAppScenarioRequest: text_embed: LocalAppTextEmbedScenarioSpec | None = None image_generate: LocalAppImageGenerateScenarioSpec | None = None + text_generate: StreamLocalAppTextTurnRequest | None = None @dataclass(frozen=True) class ExecuteLocalAppScenarioResponse: text_embed: LocalAppTextEmbedOutput | None = None image_generate: LocalAppImageGenerateOutput | None = None trace_id: str | None = None + text_generate: LocalAppTextGenerateOutput | None = None @dataclass(frozen=True) class ExecuteScenarioRequest: @@ -3862,22 +3864,35 @@ class LocalAppSpeechTranscribeJobSpec: class LocalAppTextCandidateMessage: role: str | None = None text: str | None = None + turn_items: tuple[TextTurnItem, ...] = field(default_factory=tuple) @dataclass(frozen=True) class LocalAppTextEmbedOutput: vectors: tuple[EmbeddingVector, ...] = field(default_factory=tuple) + space_id: str | None = None @dataclass(frozen=True) class LocalAppTextEmbedScenarioSpec: inputs: tuple[str, ...] = field(default_factory=tuple) +@dataclass(frozen=True) +class LocalAppTextGenerateOutput: + items: tuple[TextOutputItem, ...] = field(default_factory=tuple) + finish_reason: FinishReason | None = None + @dataclass(frozen=True) class LocalAppTextTurnCompleted: finish_reason: FinishReason | None = None +@dataclass(frozen=True) +class LocalAppTextTurnContinuity: + item_index: int | None = None + carrier: ReasoningContinuityCarrier | None = None + @dataclass(frozen=True) class LocalAppTextTurnDelta: text: str | None = None + item_index: int | None = None @dataclass(frozen=True) class LocalAppTextTurnFailed: @@ -3885,6 +3900,11 @@ class LocalAppTextTurnFailed: action_hint: str | None = None interruption: ExecutionInterruption | None = None +@dataclass(frozen=True) +class LocalAppTextTurnToolCall: + item_index: int | None = None + tool_call: ToolCall | None = None + @dataclass(frozen=True) class LocalAppVideoGenerateJobSpec: prompt: str | None = None @@ -5739,6 +5759,8 @@ class StreamLocalAppTextTurnEvent: delta: LocalAppTextTurnDelta | None = None completed: LocalAppTextTurnCompleted | None = None failed: LocalAppTextTurnFailed | None = None + tool_call: LocalAppTextTurnToolCall | None = None + reasoning_continuity: LocalAppTextTurnContinuity | None = None @dataclass(frozen=True) class StreamLocalAppTextTurnRequest: @@ -5751,6 +5773,10 @@ class StreamLocalAppTextTurnRequest: frequency_penalty: float | None = None stop: tuple[str, ...] = field(default_factory=tuple) seed: int | None = None + tools: tuple[ToolSpec, ...] = field(default_factory=tuple) + tool_choice: ToolChoiceMode | None = None + tool_choice_name: str | None = None + response_format: ResponseFormat | None = None @dataclass(frozen=True) class StreamScenarioEvent: @@ -5952,6 +5978,7 @@ class TextBehaviorCapabilityProjection: @dataclass(frozen=True) class TextEmbedOutput: vectors: tuple[EmbeddingVector, ...] = field(default_factory=tuple) + space_id: str | None = None @dataclass(frozen=True) class TextEmbedScenarioSpec: diff --git a/sdks/rust/core_generated/runtime-core.manifest.json b/sdks/rust/core_generated/runtime-core.manifest.json index e3dc222183..f592c53245 100644 --- a/sdks/rust/core_generated/runtime-core.manifest.json +++ b/sdks/rust/core_generated/runtime-core.manifest.json @@ -6993,9 +6993,12 @@ "LocalAppTextCandidateMessage", "LocalAppTextEmbedOutput", "LocalAppTextEmbedScenarioSpec", + "LocalAppTextGenerateOutput", "LocalAppTextTurnCompleted", + "LocalAppTextTurnContinuity", "LocalAppTextTurnDelta", "LocalAppTextTurnFailed", + "LocalAppTextTurnToolCall", "LocalAppVideoGenerateJobSpec", "LocalAppVideoGenerationOptions", "LocalAppVoiceAsset", @@ -16740,6 +16743,13 @@ "repeated": false, "optional": false, "number": 2 + }, + { + "name": "text_generate", + "type": "StreamLocalAppTextTurnRequest", + "repeated": false, + "optional": false, + "number": 3 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -16767,6 +16777,13 @@ "repeated": false, "optional": false, "number": 3 + }, + { + "name": "text_generate", + "type": "LocalAppTextGenerateOutput", + "repeated": false, + "optional": false, + "number": 4 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -24964,6 +24981,13 @@ "repeated": false, "optional": false, "number": 2 + }, + { + "name": "turn_items", + "type": "TextTurnItem", + "repeated": true, + "optional": false, + "number": 3 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -24977,6 +25001,13 @@ "repeated": true, "optional": false, "number": 1 + }, + { + "name": "space_id", + "type": "string", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -24994,6 +25025,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextGenerateOutput", + "fields": [ + { + "name": "items", + "type": "TextOutputItem", + "repeated": true, + "optional": false, + "number": 1 + }, + { + "name": "finish_reason", + "type": "FinishReason", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppTextTurnCompleted", "fields": [ @@ -25007,6 +25058,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextTurnContinuity", + "fields": [ + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 1 + }, + { + "name": "carrier", + "type": "ReasoningContinuityCarrier", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppTextTurnDelta", "fields": [ @@ -25016,6 +25087,13 @@ "repeated": false, "optional": false, "number": 1 + }, + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -25047,6 +25125,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextTurnToolCall", + "fields": [ + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 1 + }, + { + "name": "tool_call", + "type": "ToolCall", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppVideoGenerateJobSpec", "fields": [ @@ -34152,6 +34250,20 @@ "repeated": false, "optional": false, "number": 5 + }, + { + "name": "tool_call", + "type": "LocalAppTextTurnToolCall", + "repeated": false, + "optional": false, + "number": 6 + }, + { + "name": "reasoning_continuity", + "type": "LocalAppTextTurnContinuity", + "repeated": false, + "optional": false, + "number": 7 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -34221,6 +34333,34 @@ "repeated": false, "optional": true, "number": 9 + }, + { + "name": "tools", + "type": "ToolSpec", + "repeated": true, + "optional": false, + "number": 10 + }, + { + "name": "tool_choice", + "type": "ToolChoiceMode", + "repeated": false, + "optional": false, + "number": 11 + }, + { + "name": "tool_choice_name", + "type": "string", + "repeated": false, + "optional": false, + "number": 12 + }, + { + "name": "response_format", + "type": "ResponseFormat", + "repeated": false, + "optional": false, + "number": 13 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -35171,6 +35311,13 @@ "repeated": true, "optional": false, "number": 1 + }, + { + "name": "space_id", + "type": "string", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" diff --git a/sdks/rust/core_generated/typed_clients.rs b/sdks/rust/core_generated/typed_clients.rs index f79212e6b1..0a94f1581f 100644 --- a/sdks/rust/core_generated/typed_clients.rs +++ b/sdks/rust/core_generated/typed_clients.rs @@ -5998,6 +5998,7 @@ pub struct ErrorInfo { pub struct ExecuteLocalAppScenarioRequest { pub text_embed: Option>, pub image_generate: Option>, + pub text_generate: Option>, } #[derive(Clone, Debug, Default, PartialEq)] @@ -6005,6 +6006,7 @@ pub struct ExecuteLocalAppScenarioResponse { pub text_embed: Option>, pub image_generate: Option>, pub trace_id: Option, + pub text_generate: Option>, } #[derive(Clone, Debug, Default, PartialEq)] @@ -8340,11 +8342,13 @@ pub struct LocalAppSpeechTranscribeJobSpec { pub struct LocalAppTextCandidateMessage { pub role: Option, pub text: Option, + pub turn_items: Vec>, } #[derive(Clone, Debug, Default, PartialEq)] pub struct LocalAppTextEmbedOutput { pub vectors: Vec>, + pub space_id: Option, } #[derive(Clone, Debug, Default, PartialEq)] @@ -8352,14 +8356,27 @@ pub struct LocalAppTextEmbedScenarioSpec { pub inputs: Vec, } +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LocalAppTextGenerateOutput { + pub items: Vec>, + pub finish_reason: Option, +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct LocalAppTextTurnCompleted { pub finish_reason: Option, } +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LocalAppTextTurnContinuity { + pub item_index: Option, + pub carrier: Option>, +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct LocalAppTextTurnDelta { pub text: Option, + pub item_index: Option, } #[derive(Clone, Debug, Default, PartialEq)] @@ -8369,6 +8386,12 @@ pub struct LocalAppTextTurnFailed { pub interruption: Option>, } +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LocalAppTextTurnToolCall { + pub item_index: Option, + pub tool_call: Option>, +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct LocalAppVideoGenerateJobSpec { pub prompt: Option, @@ -10931,6 +10954,8 @@ pub struct StreamLocalAppTextTurnEvent { pub delta: Option>, pub completed: Option>, pub failed: Option>, + pub tool_call: Option>, + pub reasoning_continuity: Option>, } #[derive(Clone, Debug, Default, PartialEq)] @@ -10944,6 +10969,10 @@ pub struct StreamLocalAppTextTurnRequest { pub frequency_penalty: Option, pub stop: Vec, pub seed: Option, + pub tools: Vec>, + pub tool_choice: Option, + pub tool_choice_name: Option, + pub response_format: Option>, } #[derive(Clone, Debug, Default, PartialEq)] @@ -11185,6 +11214,7 @@ pub struct TextBehaviorCapabilityProjection { #[derive(Clone, Debug, Default, PartialEq)] pub struct TextEmbedOutput { pub vectors: Vec>, + pub space_id: Option, } #[derive(Clone, Debug, Default, PartialEq)] diff --git a/sdks/typescript/core-generated/runtime-core.manifest.json b/sdks/typescript/core-generated/runtime-core.manifest.json index 04d915ae7c..a2680506e3 100644 --- a/sdks/typescript/core-generated/runtime-core.manifest.json +++ b/sdks/typescript/core-generated/runtime-core.manifest.json @@ -7044,9 +7044,12 @@ "LocalAppTextCandidateMessage", "LocalAppTextEmbedOutput", "LocalAppTextEmbedScenarioSpec", + "LocalAppTextGenerateOutput", "LocalAppTextTurnCompleted", + "LocalAppTextTurnContinuity", "LocalAppTextTurnDelta", "LocalAppTextTurnFailed", + "LocalAppTextTurnToolCall", "LocalAppVideoGenerateJobSpec", "LocalAppVideoGenerationOptions", "LocalAppVoiceAsset", @@ -16795,6 +16798,13 @@ "repeated": false, "optional": false, "number": 2 + }, + { + "name": "text_generate", + "type": "StreamLocalAppTextTurnRequest", + "repeated": false, + "optional": false, + "number": 3 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -16822,6 +16832,13 @@ "repeated": false, "optional": false, "number": 3 + }, + { + "name": "text_generate", + "type": "LocalAppTextGenerateOutput", + "repeated": false, + "optional": false, + "number": 4 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -25019,6 +25036,13 @@ "repeated": false, "optional": false, "number": 2 + }, + { + "name": "turn_items", + "type": "TextTurnItem", + "repeated": true, + "optional": false, + "number": 3 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -25032,6 +25056,13 @@ "repeated": true, "optional": false, "number": 1 + }, + { + "name": "space_id", + "type": "string", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -25049,6 +25080,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextGenerateOutput", + "fields": [ + { + "name": "items", + "type": "TextOutputItem", + "repeated": true, + "optional": false, + "number": 1 + }, + { + "name": "finish_reason", + "type": "FinishReason", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppTextTurnCompleted", "fields": [ @@ -25062,6 +25113,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextTurnContinuity", + "fields": [ + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 1 + }, + { + "name": "carrier", + "type": "ReasoningContinuityCarrier", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppTextTurnDelta", "fields": [ @@ -25071,6 +25142,13 @@ "repeated": false, "optional": false, "number": 1 + }, + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -25102,6 +25180,26 @@ ], "source_file": "proto/runtime/v1/ai.proto" }, + { + "name": "LocalAppTextTurnToolCall", + "fields": [ + { + "name": "item_index", + "type": "uint32", + "repeated": false, + "optional": false, + "number": 1 + }, + { + "name": "tool_call", + "type": "ToolCall", + "repeated": false, + "optional": false, + "number": 2 + } + ], + "source_file": "proto/runtime/v1/ai.proto" + }, { "name": "LocalAppVideoGenerateJobSpec", "fields": [ @@ -34266,6 +34364,20 @@ "repeated": false, "optional": false, "number": 5 + }, + { + "name": "tool_call", + "type": "LocalAppTextTurnToolCall", + "repeated": false, + "optional": false, + "number": 6 + }, + { + "name": "reasoning_continuity", + "type": "LocalAppTextTurnContinuity", + "repeated": false, + "optional": false, + "number": 7 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -34335,6 +34447,34 @@ "repeated": false, "optional": true, "number": 9 + }, + { + "name": "tools", + "type": "ToolSpec", + "repeated": true, + "optional": false, + "number": 10 + }, + { + "name": "tool_choice", + "type": "ToolChoiceMode", + "repeated": false, + "optional": false, + "number": 11 + }, + { + "name": "tool_choice_name", + "type": "string", + "repeated": false, + "optional": false, + "number": 12 + }, + { + "name": "response_format", + "type": "ResponseFormat", + "repeated": false, + "optional": false, + "number": 13 } ], "source_file": "proto/runtime/v1/ai.proto" @@ -35285,6 +35425,13 @@ "repeated": true, "optional": false, "number": 1 + }, + { + "name": "space_id", + "type": "string", + "repeated": false, + "optional": false, + "number": 2 } ], "source_file": "proto/runtime/v1/ai.proto" diff --git a/sdks/typescript/core-generated/runtime-protobuf/runtime/v1/ai.ts b/sdks/typescript/core-generated/runtime-protobuf/runtime/v1/ai.ts index 9fc9af6bb7..a95aecf797 100644 --- a/sdks/typescript/core-generated/runtime-protobuf/runtime/v1/ai.ts +++ b/sdks/typescript/core-generated/runtime-protobuf/runtime/v1/ai.ts @@ -1543,6 +1543,13 @@ export interface TextEmbedOutput { * @generated from protobuf field: repeated nimi.runtime.v1.EmbeddingVector vectors = 1 */ vectors: EmbeddingVector[]; + /** + * Opaque compatibility identity of the captured embedding semantics. + * Compare actual result values before combining vectors or reusing an index. + * + * @generated from protobuf field: string space_id = 2 + */ + spaceId: string; } /** * @generated from protobuf message nimi.runtime.v1.ImageGenerateResult @@ -1801,6 +1808,13 @@ export interface LocalAppTextCandidateMessage { * @generated from protobuf field: string text = 2 */ text: string; + /** + * Ordered assistant transcript is admitted only by text-turn and the + * text.generate execute variant. The narrow candidate operation rejects it. + * + * @generated from protobuf field: repeated nimi.runtime.v1.TextTurnItem turn_items = 3 + */ + turnItems: TextTurnItem[]; } /** * @generated from protobuf message nimi.runtime.v1.GenerateLocalAppTextCandidateRequest @@ -1843,8 +1857,8 @@ export interface GenerateLocalAppTextCandidateResponse { // Exact third-party Local App scenario-consumption contracts. Runtime derives // account, App identity, App Access and managed route composition from the // protected session and the calling App owner's committed AIConfig; callers -// cannot supply generic Scenario head, route, provider, model, target, or tool -// fields. +// cannot supply generic Scenario head, route, provider, model, or target fields. +// Function tools and structured output are admitted only by the text variant. /** * Trimmed artifact projection for Local App scenario outputs. Runtime-private @@ -1993,6 +2007,14 @@ export interface ExecuteLocalAppScenarioRequest { * @generated from protobuf field: nimi.runtime.v1.LocalAppImageGenerateScenarioSpec image_generate = 2 */ imageGenerate: LocalAppImageGenerateScenarioSpec; + } | { + oneofKind: "textGenerate"; + /** + * Reuse the single text-turn input for synchronous text generation. + * + * @generated from protobuf field: nimi.runtime.v1.StreamLocalAppTextTurnRequest text_generate = 3 + */ + textGenerate: StreamLocalAppTextTurnRequest; } | { oneofKind: undefined; }; @@ -2005,6 +2027,10 @@ export interface LocalAppTextEmbedOutput { * @generated from protobuf field: repeated nimi.runtime.v1.EmbeddingVector vectors = 1 */ vectors: EmbeddingVector[]; + /** + * @generated from protobuf field: string space_id = 2 + */ + spaceId: string; } /** * @generated from protobuf message nimi.runtime.v1.LocalAppImageGenerateOutput @@ -2015,6 +2041,21 @@ export interface LocalAppImageGenerateOutput { */ artifacts: LocalAppScenarioArtifact[]; } +/** + * @generated from protobuf message nimi.runtime.v1.LocalAppTextGenerateOutput + */ +export interface LocalAppTextGenerateOutput { + /** + * Only text and function ToolCall items are admitted at this App boundary. + * + * @generated from protobuf field: repeated nimi.runtime.v1.TextOutputItem items = 1 + */ + items: TextOutputItem[]; + /** + * @generated from protobuf field: nimi.runtime.v1.FinishReason finish_reason = 2 + */ + finishReason: FinishReason; +} /** * @generated from protobuf message nimi.runtime.v1.ExecuteLocalAppScenarioResponse */ @@ -2034,6 +2075,12 @@ export interface ExecuteLocalAppScenarioResponse { * @generated from protobuf field: nimi.runtime.v1.LocalAppImageGenerateOutput image_generate = 2 */ imageGenerate: LocalAppImageGenerateOutput; + } | { + oneofKind: "textGenerate"; + /** + * @generated from protobuf field: nimi.runtime.v1.LocalAppTextGenerateOutput text_generate = 4 + */ + textGenerate: LocalAppTextGenerateOutput; } | { oneofKind: undefined; }; @@ -2556,9 +2603,9 @@ export interface LocalAppScenarioJobEvent { job?: LocalAppScenarioJob; } /** - * Streaming TEXT_GENERATE trimmed surface: typed text increments and terminal - * finish or failure state only. Raw chunks, reasoning traces, sources, and - * tool events never reach the Local App stream. + * Single-step TEXT_GENERATE App surface. Function tools, ordered host-provided + * results and structured output reuse canonical text behavior. The external + * AI host owns tool execution and the next request; Runtime owns no agent loop. * * @generated from protobuf message nimi.runtime.v1.StreamLocalAppTextTurnRequest */ @@ -2599,6 +2646,22 @@ export interface StreamLocalAppTextTurnRequest { * @generated from protobuf field: optional int64 seed = 9 */ seed?: string; + /** + * @generated from protobuf field: repeated nimi.runtime.v1.ToolSpec tools = 10 + */ + tools: ToolSpec[]; + /** + * @generated from protobuf field: nimi.runtime.v1.ToolChoiceMode tool_choice = 11 + */ + toolChoice: ToolChoiceMode; + /** + * @generated from protobuf field: string tool_choice_name = 12 + */ + toolChoiceName: string; + /** + * @generated from protobuf field: nimi.runtime.v1.ResponseFormat response_format = 13 + */ + responseFormat?: ResponseFormat; } /** * @generated from protobuf message nimi.runtime.v1.LocalAppTextTurnDelta @@ -2608,6 +2671,38 @@ export interface LocalAppTextTurnDelta { * @generated from protobuf field: string text = 1 */ text: string; + /** + * @generated from protobuf field: uint32 item_index = 2 + */ + itemIndex: number; +} +/** + * @generated from protobuf message nimi.runtime.v1.LocalAppTextTurnToolCall + */ +export interface LocalAppTextTurnToolCall { + /** + * @generated from protobuf field: uint32 item_index = 1 + */ + itemIndex: number; + /** + * @generated from protobuf field: nimi.runtime.v1.ToolCall tool_call = 2 + */ + toolCall?: ToolCall; +} +/** + * Opaque transcript continuity, never raw reasoning or a reasoning control. + * + * @generated from protobuf message nimi.runtime.v1.LocalAppTextTurnContinuity + */ +export interface LocalAppTextTurnContinuity { + /** + * @generated from protobuf field: uint32 item_index = 1 + */ + itemIndex: number; + /** + * @generated from protobuf field: nimi.runtime.v1.ReasoningContinuityCarrier carrier = 2 + */ + carrier?: ReasoningContinuityCarrier; } /** * @generated from protobuf message nimi.runtime.v1.LocalAppTextTurnCompleted @@ -2668,6 +2763,18 @@ export interface StreamLocalAppTextTurnEvent { * @generated from protobuf field: nimi.runtime.v1.LocalAppTextTurnFailed failed = 5 */ failed: LocalAppTextTurnFailed; + } | { + oneofKind: "toolCall"; + /** + * @generated from protobuf field: nimi.runtime.v1.LocalAppTextTurnToolCall tool_call = 6 + */ + toolCall: LocalAppTextTurnToolCall; + } | { + oneofKind: "reasoningContinuity"; + /** + * @generated from protobuf field: nimi.runtime.v1.LocalAppTextTurnContinuity reasoning_continuity = 7 + */ + reasoningContinuity: LocalAppTextTurnContinuity; } | { oneofKind: undefined; }; @@ -8751,12 +8858,14 @@ export const EmbeddingVector = new EmbeddingVector$Type(); class TextEmbedOutput$Type extends MessageType { constructor() { super("nimi.runtime.v1.TextEmbedOutput", [ - { no: 1, name: "vectors", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => EmbeddingVector } + { no: 1, name: "vectors", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => EmbeddingVector }, + { no: 2, name: "space_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ } ]); } create(value?: PartialMessage): TextEmbedOutput { const message = globalThis.Object.create((this.messagePrototype!)); message.vectors = []; + message.spaceId = ""; if (value !== undefined) reflectionMergePartial(this, message, value); return message; @@ -8769,6 +8878,9 @@ class TextEmbedOutput$Type extends MessageType { case /* repeated nimi.runtime.v1.EmbeddingVector vectors */ 1: message.vectors.push(EmbeddingVector.internalBinaryRead(reader, reader.uint32(), options)); break; + case /* string space_id */ 2: + message.spaceId = reader.string(); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -8784,6 +8896,9 @@ class TextEmbedOutput$Type extends MessageType { /* repeated nimi.runtime.v1.EmbeddingVector vectors = 1; */ for (let i = 0; i < message.vectors.length; i++) EmbeddingVector.internalBinaryWrite(message.vectors[i], writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + /* string space_id = 2; */ + if (message.spaceId !== "") + writer.tag(2, WireType.LengthDelimited).string(message.spaceId); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -9572,13 +9687,15 @@ class LocalAppTextCandidateMessage$Type extends MessageType TextTurnItem } ]); } create(value?: PartialMessage): LocalAppTextCandidateMessage { const message = globalThis.Object.create((this.messagePrototype!)); message.role = ""; message.text = ""; + message.turnItems = []; if (value !== undefined) reflectionMergePartial(this, message, value); return message; @@ -9594,6 +9711,9 @@ class LocalAppTextCandidateMessage$Type extends MessageType LocalAppTextEmbedScenarioSpec }, - { no: 2, name: "image_generate", kind: "message", oneof: "spec", T: () => LocalAppImageGenerateScenarioSpec } + { no: 2, name: "image_generate", kind: "message", oneof: "spec", T: () => LocalAppImageGenerateScenarioSpec }, + { no: 3, name: "text_generate", kind: "message", oneof: "spec", T: () => StreamLocalAppTextTurnRequest } ]); } create(value?: PartialMessage): ExecuteLocalAppScenarioRequest { @@ -10106,6 +10230,12 @@ class ExecuteLocalAppScenarioRequest$Type extends MessageType { constructor() { super("nimi.runtime.v1.LocalAppTextEmbedOutput", [ - { no: 1, name: "vectors", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => EmbeddingVector } + { no: 1, name: "vectors", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => EmbeddingVector }, + { no: 2, name: "space_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ } ]); } create(value?: PartialMessage): LocalAppTextEmbedOutput { const message = globalThis.Object.create((this.messagePrototype!)); message.vectors = []; + message.spaceId = ""; if (value !== undefined) reflectionMergePartial(this, message, value); return message; @@ -10156,6 +10291,9 @@ class LocalAppTextEmbedOutput$Type extends MessageType case /* repeated nimi.runtime.v1.EmbeddingVector vectors */ 1: message.vectors.push(EmbeddingVector.internalBinaryRead(reader, reader.uint32(), options)); break; + case /* string space_id */ 2: + message.spaceId = reader.string(); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -10171,6 +10309,9 @@ class LocalAppTextEmbedOutput$Type extends MessageType /* repeated nimi.runtime.v1.EmbeddingVector vectors = 1; */ for (let i = 0; i < message.vectors.length; i++) EmbeddingVector.internalBinaryWrite(message.vectors[i], writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + /* string space_id = 2; */ + if (message.spaceId !== "") + writer.tag(2, WireType.LengthDelimited).string(message.spaceId); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -10229,11 +10370,67 @@ class LocalAppImageGenerateOutput$Type extends MessageType { + constructor() { + super("nimi.runtime.v1.LocalAppTextGenerateOutput", [ + { no: 1, name: "items", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => TextOutputItem }, + { no: 2, name: "finish_reason", kind: "enum", T: () => ["nimi.runtime.v1.FinishReason", FinishReason, "FINISH_REASON_"] } + ]); + } + create(value?: PartialMessage): LocalAppTextGenerateOutput { + const message = globalThis.Object.create((this.messagePrototype!)); + message.items = []; + message.finishReason = 0; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: LocalAppTextGenerateOutput): LocalAppTextGenerateOutput { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated nimi.runtime.v1.TextOutputItem items */ 1: + message.items.push(TextOutputItem.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* nimi.runtime.v1.FinishReason finish_reason */ 2: + message.finishReason = reader.int32(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: LocalAppTextGenerateOutput, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated nimi.runtime.v1.TextOutputItem items = 1; */ + for (let i = 0; i < message.items.length; i++) + TextOutputItem.internalBinaryWrite(message.items[i], writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + /* nimi.runtime.v1.FinishReason finish_reason = 2; */ + if (message.finishReason !== 0) + writer.tag(2, WireType.Varint).int32(message.finishReason); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message nimi.runtime.v1.LocalAppTextGenerateOutput + */ +export const LocalAppTextGenerateOutput = new LocalAppTextGenerateOutput$Type(); +// @generated message type with reflection information, may provide speed optimized methods class ExecuteLocalAppScenarioResponse$Type extends MessageType { constructor() { super("nimi.runtime.v1.ExecuteLocalAppScenarioResponse", [ { no: 1, name: "text_embed", kind: "message", oneof: "output", T: () => LocalAppTextEmbedOutput }, { no: 2, name: "image_generate", kind: "message", oneof: "output", T: () => LocalAppImageGenerateOutput }, + { no: 4, name: "text_generate", kind: "message", oneof: "output", T: () => LocalAppTextGenerateOutput }, { no: 3, name: "trace_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ } ]); } @@ -10262,6 +10459,12 @@ class ExecuteLocalAppScenarioResponse$Type extends MessageType ToolSpec }, + { no: 11, name: "tool_choice", kind: "enum", T: () => ["nimi.runtime.v1.ToolChoiceMode", ToolChoiceMode, "TOOL_CHOICE_MODE_"] }, + { no: 12, name: "tool_choice_name", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 13, name: "response_format", kind: "message", T: () => ResponseFormat } ]); } create(value?: PartialMessage): StreamLocalAppTextTurnRequest { const message = globalThis.Object.create((this.messagePrototype!)); message.messages = []; message.stop = []; + message.tools = []; + message.toolChoice = 0; + message.toolChoiceName = ""; if (value !== undefined) reflectionMergePartial(this, message, value); return message; @@ -11717,6 +11930,18 @@ class StreamLocalAppTextTurnRequest$Type extends MessageType { constructor() { super("nimi.runtime.v1.LocalAppTextTurnDelta", [ - { no: 1, name: "text", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + { no: 1, name: "text", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "item_index", kind: "scalar", T: 13 /*ScalarType.UINT32*/ } ]); } create(value?: PartialMessage): LocalAppTextTurnDelta { const message = globalThis.Object.create((this.messagePrototype!)); message.text = ""; + message.itemIndex = 0; if (value !== undefined) reflectionMergePartial(this, message, value); return message; @@ -11788,6 +12027,9 @@ class LocalAppTextTurnDelta$Type extends MessageType { case /* string text */ 1: message.text = reader.string(); break; + case /* uint32 item_index */ 2: + message.itemIndex = reader.uint32(); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -11803,6 +12045,9 @@ class LocalAppTextTurnDelta$Type extends MessageType { /* string text = 1; */ if (message.text !== "") writer.tag(1, WireType.LengthDelimited).string(message.text); + /* uint32 item_index = 2; */ + if (message.itemIndex !== 0) + writer.tag(2, WireType.Varint).uint32(message.itemIndex); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -11814,6 +12059,114 @@ class LocalAppTextTurnDelta$Type extends MessageType { */ export const LocalAppTextTurnDelta = new LocalAppTextTurnDelta$Type(); // @generated message type with reflection information, may provide speed optimized methods +class LocalAppTextTurnToolCall$Type extends MessageType { + constructor() { + super("nimi.runtime.v1.LocalAppTextTurnToolCall", [ + { no: 1, name: "item_index", kind: "scalar", T: 13 /*ScalarType.UINT32*/ }, + { no: 2, name: "tool_call", kind: "message", T: () => ToolCall } + ]); + } + create(value?: PartialMessage): LocalAppTextTurnToolCall { + const message = globalThis.Object.create((this.messagePrototype!)); + message.itemIndex = 0; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: LocalAppTextTurnToolCall): LocalAppTextTurnToolCall { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* uint32 item_index */ 1: + message.itemIndex = reader.uint32(); + break; + case /* nimi.runtime.v1.ToolCall tool_call */ 2: + message.toolCall = ToolCall.internalBinaryRead(reader, reader.uint32(), options, message.toolCall); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: LocalAppTextTurnToolCall, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* uint32 item_index = 1; */ + if (message.itemIndex !== 0) + writer.tag(1, WireType.Varint).uint32(message.itemIndex); + /* nimi.runtime.v1.ToolCall tool_call = 2; */ + if (message.toolCall) + ToolCall.internalBinaryWrite(message.toolCall, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message nimi.runtime.v1.LocalAppTextTurnToolCall + */ +export const LocalAppTextTurnToolCall = new LocalAppTextTurnToolCall$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class LocalAppTextTurnContinuity$Type extends MessageType { + constructor() { + super("nimi.runtime.v1.LocalAppTextTurnContinuity", [ + { no: 1, name: "item_index", kind: "scalar", T: 13 /*ScalarType.UINT32*/ }, + { no: 2, name: "carrier", kind: "message", T: () => ReasoningContinuityCarrier } + ]); + } + create(value?: PartialMessage): LocalAppTextTurnContinuity { + const message = globalThis.Object.create((this.messagePrototype!)); + message.itemIndex = 0; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: LocalAppTextTurnContinuity): LocalAppTextTurnContinuity { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* uint32 item_index */ 1: + message.itemIndex = reader.uint32(); + break; + case /* nimi.runtime.v1.ReasoningContinuityCarrier carrier */ 2: + message.carrier = ReasoningContinuityCarrier.internalBinaryRead(reader, reader.uint32(), options, message.carrier); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: LocalAppTextTurnContinuity, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* uint32 item_index = 1; */ + if (message.itemIndex !== 0) + writer.tag(1, WireType.Varint).uint32(message.itemIndex); + /* nimi.runtime.v1.ReasoningContinuityCarrier carrier = 2; */ + if (message.carrier) + ReasoningContinuityCarrier.internalBinaryWrite(message.carrier, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message nimi.runtime.v1.LocalAppTextTurnContinuity + */ +export const LocalAppTextTurnContinuity = new LocalAppTextTurnContinuity$Type(); +// @generated message type with reflection information, may provide speed optimized methods class LocalAppTextTurnCompleted$Type extends MessageType { constructor() { super("nimi.runtime.v1.LocalAppTextTurnCompleted", [ @@ -11930,7 +12283,9 @@ class StreamLocalAppTextTurnEvent$Type extends MessageType LocalAppTextTurnDelta }, { no: 4, name: "completed", kind: "message", oneof: "payload", T: () => LocalAppTextTurnCompleted }, - { no: 5, name: "failed", kind: "message", oneof: "payload", T: () => LocalAppTextTurnFailed } + { no: 5, name: "failed", kind: "message", oneof: "payload", T: () => LocalAppTextTurnFailed }, + { no: 6, name: "tool_call", kind: "message", oneof: "payload", T: () => LocalAppTextTurnToolCall }, + { no: 7, name: "reasoning_continuity", kind: "message", oneof: "payload", T: () => LocalAppTextTurnContinuity } ]); } create(value?: PartialMessage): StreamLocalAppTextTurnEvent { @@ -11971,6 +12326,18 @@ class StreamLocalAppTextTurnEvent$Type extends MessageType AsyncIterable, onCancel: () => void = () => {}, executeOutput?: unknown) { + const inputs: NimiLocalAppTextTurnInput[] = []; + let canceled = 0; + const unused = async (): Promise => { throw new Error('unused fixture operation'); }; + const ai = createNimiLocalAppAIConsumptionClient({ + text: { streamTurn: async (input) => { + inputs.push(input); + return { events: events(), cancel: async () => { canceled++; onCancel(); } }; + } }, + scenario: { execute: executeOutput === undefined ? unused : async () => executeOutput }, + scenarioJobs: { submit: unused, get: unused, subscribe: unused, cancel: unused }, + artifacts: { read: unused, upload: unused }, + voiceAssets: { list: unused }, + }); + return { ai, model: createNimiLocalAppTextModel(ai), inputs, canceled: () => canceled }; +} + +const user = { role: 'user' as const, content: [{ type: 'text' as const, text: 'Search.' }] }; +const tool = { name: 'search', inputSchema: { type: 'object' } }; +const call = { id: 'call-1', name: 'search', arguments: { query: 'Nimi', token: 'business data' } }; + +test('opaque continuity survives the native JSON boundary and the next model turn', async () => { + const carrier = { kind: 'test.encrypted', version: 1, payload: [0, 127, 255] }; + const f = fixture(async function* () { + yield { type: 'reasoning-continuity', sequence: '1', traceId: 'trace-continuity', itemIndex: 0, carrier }; + yield { type: 'tool-call', sequence: '2', traceId: 'trace-continuity', itemIndex: 1, toolCall: call }; + yield { type: 'completed', sequence: '3', traceId: 'trace-continuity', finishReason: 'tool-calls' }; + }); + const first = await f.model.generateText({ messages: [user], tools: [tool] }); + assert.deepEqual(first.outputItems?.[0], { type: 'reasoning-continuity', carrier: { ...carrier, payload: new Uint8Array(carrier.payload) } }); + await f.model.generateText({ messages: [user, { role: 'assistant', content: [], turnItems: [ + ...first.outputItems!.map((output) => ({ type: 'output' as const, output })), + { type: 'tool-result', toolResult: { toolCallId: call.id, toolName: call.name, result: 'found' } }, + ] }], tools: [tool] }); + assert.deepEqual(f.inputs[1].messages[1].turnItems?.[0], { type: 'output', output: { type: 'reasoning-continuity', carrier } }); +}); + +test('invalid or carrier-only output cannot complete a Local App step', async () => { + for (const payload of [[1], [], [256], Array(64 * 1024 + 1).fill(1)]) { + const f = fixture(async function* () { + yield { type: 'reasoning-continuity', sequence: '1', traceId: 'trace-continuity', itemIndex: 0, carrier: { kind: 'test', version: 1, payload } }; + yield { type: 'completed', sequence: '2', traceId: 'trace-continuity', finishReason: 'stop' }; + }); + await assert.rejects(f.model.generateText({ messages: [user] }), { reasonCode: 'SDK_LOCAL_APP_PROJECTION_INVALID' }); + } +}); + +test('sync and streamed output preserve native content after JSON representation expands', async () => { + const payload = new TextEncoder().encode(JSON.stringify({ + type: 'reasoning', id: 'rs_budget', encrypted_content: 'A'.repeat(60 * 1024), summary: [], + })); + const carrier = { kind: 'openai_codex.responses.encrypted-reasoning', version: 1, payload: Array.from(payload) }; + // Runtime/native retain this compact numeric spelling. The JSON projection + // carries the same values, whose JS serialization uses much longer decimals. + const rawArguments = `{"values":[${Array(16 * 1024).fill('1e20').join(',')}]}`; + const text = 'x'.repeat(100 * 1024); + for (const toolCall of [call, { ...call, arguments: JSON.parse(rawArguments) }]) { + const items = [{ type: 'reasoning-continuity', carrier }, { type: 'tool-call', toolCall }, { type: 'text', text }]; + const f = fixture(async function* () { + let sequence = 0; + yield { type: 'reasoning-continuity', sequence: String(++sequence), traceId: 'trace-budget', itemIndex: 0, carrier }; + yield { type: 'tool-call', sequence: String(++sequence), traceId: 'trace-budget', itemIndex: 1, toolCall }; + for (let offset = 0; offset < text.length; offset += 64 * 1024) { + yield { type: 'delta', sequence: String(++sequence), traceId: 'trace-budget', itemIndex: 2, text: text.slice(offset, offset + 64 * 1024) }; + } + yield { type: 'completed', sequence: String(++sequence), traceId: 'trace-budget', finishReason: 'tool-calls' }; + }, undefined, { output: { type: 'text-generate', items, finishReason: 'tool-calls' }, traceId: 'trace-budget' }); + const sync = await f.ai.scenario.execute({ type: 'text-generate', messages: [{ role: 'user', text: 'Answer.' }], tools: [tool] }); + assert.equal(sync.output.type, 'text-generate'); + assert.deepEqual(sync.output.type === 'text-generate' && sync.output.items, items); + const streamed = await f.model.generateText({ messages: [user], tools: [tool] }); + assert.equal(streamed.text, text); + assert.deepEqual(streamed.toolCalls, [toolCall]); + } +}); + +test('sync output rejects an individually oversized text item', async () => { + const f = fixture(async function* () {}, undefined, { output: { + type: 'text-generate', items: [{ type: 'text', text: 'x'.repeat(256 * 1024 + 1) }], finishReason: 'stop', + }, traceId: 'trace-size' }); + await assert.rejects(f.ai.scenario.execute({ type: 'text-generate', messages: [{ role: 'user', text: 'Answer.' }] }), { reasonCode: 'SDK_LOCAL_APP_PROJECTION_INVALID' }); +}); + +test('Local App model preserves common tool values without executing callbacks', async () => { + const f = fixture(async function* () { + yield { type: 'tool-call', sequence: '1', traceId: 'trace-1', itemIndex: 0, toolCall: call }; + yield { type: 'completed', sequence: '2', traceId: 'trace-1', finishReason: 'tool-calls' }; + }); + let executions = 0; + const result = await f.model.generateText({ messages: [user], tools: [{ ...tool, execute: () => { executions++; return null; } }] }); + assert.equal(f.model.model.modelId, 'text.generate'); + assert.deepEqual(result.toolCalls, [call]); + assert.deepEqual(result.outputItems, [{ type: 'tool-call', toolCall: call }]); + assert.equal(result.finishReason, 'tool-calls'); + assert.equal(executions, 0); + assert.deepEqual(f.inputs[0].tools, [{ type: 'function', ...tool }]); + assert.equal(f.canceled(), 1); +}); + +test('later explicit ordered results remain paired to the original tool call', async () => { + const f = fixture(async function* () { + yield { type: 'delta', sequence: '1', traceId: 'trace-2', itemIndex: 0, text: 'Answer.' }; + yield { type: 'completed', sequence: '2', traceId: 'trace-2', finishReason: 'stop' }; + }); + const turnItems = [ + { type: 'output' as const, output: { type: 'tool-call' as const, toolCall: call } }, + { type: 'tool-result' as const, toolResult: { toolCallId: call.id, toolName: call.name, result: { items: ['source'] } } }, + ]; + const result = await f.model.generateText({ messages: [user, { role: 'assistant', content: [], turnItems }], tools: [tool], toolChoice: 'none' }); + assert.equal(result.text, 'Answer.'); + assert.deepEqual(f.inputs[0].messages[1].turnItems, turnItems); + assert.equal(f.inputs[0].toolChoice, 'none'); +}); + +test('structured response constraints reach the formal text carrier', async () => { + const f = fixture(async function* () { + yield { type: 'delta', sequence: '1', traceId: 'trace-json', itemIndex: 0, text: '{"answer":1}' }; + yield { type: 'completed', sequence: '2', traceId: 'trace-json', finishReason: 'stop' }; + }); + const responseFormat = { type: 'json-schema' as const, schema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] }, strict: true }; + await f.model.generateText({ messages: [user], responseFormat }); + assert.deepEqual(f.inputs[0].responseFormat, responseFormat); +}); + +test('abort closes the active carrier and does not return a partial success', async () => { + const waiting = Promise.withResolvers(); + const closed = Promise.withResolvers(); + const f = fixture(async function* () { + yield { type: 'delta', sequence: '1', traceId: 'trace-cancel', itemIndex: 0, text: 'Partial' }; + waiting.resolve(); + await closed.promise; + }, () => closed.resolve()); + const controller = new AbortController(); + const pending = f.model.generateText({ messages: [user], signal: controller.signal }); + await waiting.promise; + controller.abort(); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(f.canceled(), 1); +}); + +for (const scenario of ['sequence', 'terminal'] as const) { + test(`Local App text stream rejects an invalid ${scenario}`, async () => { + const f = fixture(async function* () { + yield { type: 'delta', sequence: '1', traceId: 'trace-invalid', itemIndex: 0, text: 'Partial' }; + if (scenario === 'sequence') yield { type: 'completed', sequence: '3', traceId: 'trace-invalid', finishReason: 'stop' }; + }); + await assert.rejects(f.model.generateText({ messages: [user] }), (error: unknown) => { + assert.equal((error as { reasonCode?: string }).reasonCode, 'SDK_LOCAL_APP_PROJECTION_INVALID'); + assert.match((error as Error).message, new RegExp(scenario)); + return true; + }); + assert.equal(f.canceled(), 1); + }); +} + +test('unrepresentable provider tools fail before transport', async () => { + const f = fixture(async function* () { throw new Error('must not open'); }); + await assert.rejects(f.model.generateText({ messages: [user], tools: [{ type: 'provider', id: 'remote-tool', name: 'remote', args: {} }] }), { reasonCode: 'SDK_LOCAL_APP_INPUT_INVALID' }); + assert.equal(f.inputs.length, 0); +}); diff --git a/sdks/typescript/core/ai/local-app-model.ts b/sdks/typescript/core/ai/local-app-model.ts new file mode 100644 index 0000000000..3de79c3dc2 --- /dev/null +++ b/sdks/typescript/core/ai/local-app-model.ts @@ -0,0 +1,100 @@ +import type { NimiLocalAppAIConsumptionClient } from '../app/local-app-runtime-platform-ai'; +import { validateLocalAppTextInput, modelTextOutputToLocalApp, type NimiLocalAppTextTurnInput, type NimiLocalAppTextTurnItem } from '../app/local-app-text'; +import { assertExactKeys, localAppError } from '../app/local-app-runtime-platform-validation'; +import type { NimiRunEvent } from '../contracts'; +import { createNimiError } from '../../types'; +import { collectNimiTextStream, type NimiAiModel, type NimiGenerateTextRequest } from './index'; + +function invalid(detail: string): never { + return localAppError(`Local App text model cannot represent ${detail}.`, 'SDK_LOCAL_APP_INPUT_INVALID', 'use_admitted_local_app_text_fields'); +} + +function localInput(request: NimiGenerateTextRequest): NimiLocalAppTextTurnInput { + assertExactKeys(request, ['messages', 'tools', 'toolChoice', 'responseFormat', 'parameters', 'signal'], 'text model request'); + const parameters = request.parameters ?? {}; + assertExactKeys(parameters, ['temperature', 'topP', 'topK', 'maxTokens', 'presencePenalty', 'frequencyPenalty', 'stop', 'seed'], 'Local App text parameters'); + if (!Array.isArray(request.messages)) invalid('messages'); + const messages = request.messages.map((message) => { + assertExactKeys(message, ['role', 'content', 'turnItems'], 'Local App model message'); + if (!Array.isArray(message.content)) invalid('message content'); + const items = message.turnItems; + if (items !== undefined && !Array.isArray(items)) invalid('ordered transcript'); + if (Array.isArray(items) && items.length > 0) { + if (message.content.length !== 0 || (message.role !== 'assistant' && message.role !== 'tool')) invalid('ordered transcript'); + if (message.role === 'tool' && items.some((item) => item?.type !== 'tool-result')) invalid('tool transcript'); + const turnItems = items.map((item) => item.type === 'output' + ? { ...item, output: modelTextOutputToLocalApp(item.output) } : item); + return { role: 'assistant' as const, text: '', turnItems: turnItems as readonly NimiLocalAppTextTurnItem[] }; + } + if (message.role !== 'system' && message.role !== 'user') invalid('non-canonical assistant transcript'); + const text = message.content.map((part) => { + assertExactKeys(part, ['type', 'text'], 'Local App message part'); + if (part.type !== 'text' || typeof part.text !== 'string') invalid('non-text content'); + return part.text; + }).join(''); + return { role: message.role, text }; + }); + if (request.tools !== undefined && !Array.isArray(request.tools)) invalid('tools'); + const tools = request.tools?.map((tool) => { + if (tool.type === 'provider') invalid('provider tools'); + assertExactKeys(tool, ['type', 'name', 'description', 'inputSchema', 'execute', 'policy', 'visibility'], 'Local App function tool'); + if ((tool.execute !== undefined && typeof tool.execute !== 'function') || tool.visibility === 'internal') invalid('function tool metadata'); + // Execution/approval policy stays with the caller. A model only receives + // the declaration; this binding never invokes the callback. + return { type: 'function' as const, name: tool.name, description: tool.description, inputSchema: tool.inputSchema }; + }); + return validateLocalAppTextInput({ + messages, tools, toolChoice: request.toolChoice, responseFormat: request.responseFormat, + ...parameters, stop: typeof parameters.stop === 'string' ? [parameters.stop] : parameters.stop, + }); +} + +/** Bind the common single-step model interface to the current protected App. + * generateText collects the same cancellable stream; neither method executes + * tools or owns a multi-step workflow. + */ +// @nimi-authority: rule.nimi.sdks.feature-clients.local-app-text-behaviors +export function createNimiLocalAppTextModel(ai: Pick): NimiAiModel { + async function* streamText(request: NimiGenerateTextRequest): AsyncGenerator { + request.signal?.throwIfAborted(); + const input = localInput(request); + let stream: Awaited> | undefined; + let close: Promise | undefined; + const abort = () => { + if (stream) { + close ??= stream.cancel(); + void close.catch(() => {}); // observed by finally, not an unhandled rejection + } + }; + request.signal?.addEventListener('abort', abort, { once: true }); + try { + stream = await ai.text.streamTurn(input); + if (request.signal?.aborted) { abort(); request.signal.throwIfAborted(); } + for await (const event of stream) { + request.signal?.throwIfAborted(); + if (event.type === 'delta') yield { type: 'text-delta', text: event.text, itemIndex: event.itemIndex }; + else if (event.type === 'tool-call') yield { type: 'tool-call', toolCall: event.toolCall, itemIndex: event.itemIndex }; + else if (event.type === 'reasoning-continuity') yield { type: 'reasoning-continuity', carrier: { ...event.carrier, payload: new Uint8Array(event.carrier.payload) }, itemIndex: event.itemIndex, itemCompleted: true }; + else if (event.type === 'completed') yield { type: 'done', finishReason: event.finishReason }; + else throw createNimiError({ + message: `Nimi text generation failed: ${event.reasonCode}.`, + code: event.reasonCode, reasonCode: event.reasonCode, actionHint: event.actionHint, + source: 'runtime', interruption: event.interruption, + retryable: event.interruption?.resubmitDisposition === 'caller-may-resubmit', + }); + } + request.signal?.throwIfAborted(); + } catch (error) { + request.signal?.throwIfAborted(); + throw error; + } finally { + request.signal?.removeEventListener('abort', abort); + if (stream) await (close ??= stream.cancel()); + } + } + return Object.freeze({ + model: Object.freeze({ modelId: 'text.generate' as const }), + generateText: (request: NimiGenerateTextRequest) => collectNimiTextStream(streamText(request)), + streamText, + }); +} diff --git a/sdks/typescript/core/app/index.ts b/sdks/typescript/core/app/index.ts index 393279a6a3..0780c7beb3 100644 --- a/sdks/typescript/core/app/index.ts +++ b/sdks/typescript/core/app/index.ts @@ -1,4 +1,13 @@ import { createNimiError } from '../../types'; +export type { NimiLocalAppTextMessage, NimiLocalAppTextTurnItem, NimiLocalAppTextOutputItem, NimiLocalAppFunctionTool, NimiLocalAppToolCall } from './local-app-text.js'; +export { + validateLocalAppTextInput as validateNimiLocalAppTextInput, + projectLocalAppTextItems as validateNimiLocalAppTextOutputItems, +} from './local-app-text.js'; +export { createNimiLocalAppAIConsumptionClient, type NimiLocalAppAIConsumptionClient } from './local-app-runtime-platform-ai.js'; +export { createNimiLocalAppAIConfigClient } from './local-app-runtime-platform-ai-config.js'; +export { createNimiAppRuntimeStorageClient } from './local-app-runtime-platform-protected-operations.js'; +export { createNimiLocalAppAssetsClient } from './local-app-runtime-platform-assets.js'; import { validateNimiAppInventoryEntry, validateNimiAppStatus, diff --git a/sdks/typescript/core/app/local-app-runtime-platform-ai.ts b/sdks/typescript/core/app/local-app-runtime-platform-ai.ts index 6b16e1bc37..b812a361cb 100644 --- a/sdks/typescript/core/app/local-app-runtime-platform-ai.ts +++ b/sdks/typescript/core/app/local-app-runtime-platform-ai.ts @@ -2,6 +2,8 @@ import { ExecutionMode, FaceSwapNoFacePolicy, FinishReason, + ResponseFormatKind, + ToolChoiceMode, ReasonCode as RuntimeReasonCode, RoutePolicy, ScenarioJobEventType, @@ -46,6 +48,12 @@ import { type VoiceReference, } from '../../core-generated/runtime-typed-client.js'; import type { Timestamp } from '../../core-generated/runtime-protobuf/google/protobuf/timestamp.js'; +import { toRuntimeMessages, toRuntimeTools, toRuntimeStruct, toNimiToolCall, toNimiTextOutputItems } from '../ai/runtime-model-text-projection.js'; +import { + validateLocalAppTextInput, projectLocalAppToolCall, projectLocalAppTextItems, projectLocalAppContinuity, + modelTextOutputToLocalApp, localAppTextOutputToModel, + type NimiLocalAppTextOutputItem, type NimiLocalAppToolCall, type NimiLocalAppReasoningContinuityCarrier, +} from './local-app-text.js'; import type { NimiProtectedLocalScenarioJobClient, NimiProtectedLocalVoiceAsset, @@ -91,6 +99,7 @@ export type NimiLocalAppImageGenerateSpec = { }; export type NimiLocalAppScenarioExecuteSpec = + | (NimiLocalAppTextTurnInput & { readonly type: 'text-generate' }) | { readonly type: 'text-embed'; readonly inputs: readonly string[] } | NimiLocalAppImageGenerateSpec; @@ -253,7 +262,8 @@ export type NimiLocalAppVoiceAsset = { }; export type NimiLocalAppScenarioExecuteResult = - | { readonly output: { readonly type: 'text-embed'; readonly vectors: readonly (readonly number[])[] }; readonly traceId: string } + | { readonly output: { readonly type: 'text-generate'; readonly items: readonly NimiLocalAppTextOutputItem[]; readonly finishReason: 'stop' | 'length' | 'tool-calls' | 'content-filter' }; readonly traceId: string } + | { readonly output: { readonly type: 'text-embed'; readonly vectors: readonly (readonly number[])[]; readonly spaceId: string }; readonly traceId: string } | { readonly output: { readonly type: 'image-generate'; readonly artifacts: readonly NimiLocalAppScenarioArtifact[] }; readonly traceId: string }; export type NimiLocalAppScenarioJobSubmitResult = { @@ -275,9 +285,11 @@ export type NimiLocalAppArtifactUploadResult = { }; export type NimiLocalAppTextTurnEvent = - | { readonly type: 'delta'; readonly sequence: string; readonly traceId: string; readonly text: string } - | { readonly type: 'completed'; readonly sequence: string; readonly traceId: string; readonly finishReason: 'stop' | 'length' | 'content-filter' } - | { readonly type: 'failed'; readonly sequence: string; readonly traceId: string; readonly reasonCode: string; readonly actionHint: string }; + | { readonly type: 'reasoning-continuity'; readonly sequence: string; readonly traceId: string; readonly itemIndex: number; readonly carrier: NimiLocalAppReasoningContinuityCarrier } + | { readonly type: 'delta'; readonly sequence: string; readonly traceId: string; readonly text: string; readonly itemIndex: number } + | { readonly type: 'tool-call'; readonly sequence: string; readonly traceId: string; readonly itemIndex: number; readonly toolCall: NimiLocalAppToolCall } + | { readonly type: 'completed'; readonly sequence: string; readonly traceId: string; readonly finishReason: 'stop' | 'length' | 'tool-calls' | 'content-filter' } + | { readonly type: 'failed'; readonly sequence: string; readonly traceId: string; readonly reasonCode: string; readonly actionHint: string; readonly interruption?: NimiLocalAppExecutionInterruption }; export type NimiLocalAppScenarioJobEvent = { readonly eventType: 'submitted' | 'queued' | 'running' | 'completed' | 'failed' | 'canceled' | 'timeout'; @@ -416,18 +428,35 @@ export function createNimiLocalAppAIConsumptionClient( const client: NimiLocalAppAIConsumptionClient = { text: Object.freeze({ streamTurn: async (input) => { - let resultBytes = 0; - return projectSubscription( - await shell.text.streamTurn(validateTextTurnInput(input)), - (value) => { - const event = projectTextTurnEvent(value); - if (event.type === 'delta') { - resultBytes += utf8Length(event.text); - if (resultBytes > MAX_RESULT_BYTES) localAppProjectionError('text-turn result size'); - } - return event; + const stream = projectSubscription(await shell.text.streamTurn(validateLocalAppTextInput(input)), projectTextTurnEvent); + let canceled = false; + let closing: Promise | undefined; + const cancel = () => { canceled = true; return closing ??= stream.cancel(); }; + return Object.freeze({ + async *[Symbol.asyncIterator]() { + let sequence = 0n; + let terminal = false; + let hasPrimary = false; + const toolIds = new Set(); + try { + for await (const event of stream) { + if (canceled) break; + if (terminal || BigInt(event.sequence) !== ++sequence) localAppProjectionError('text-turn event sequence'); + if (event.type === 'delta') hasPrimary = true; + if (event.type === 'tool-call') { + hasPrimary = true; + if (toolIds.has(event.toolCall.id)) localAppProjectionError('duplicate text tool call'); + toolIds.add(event.toolCall.id); + } + if (event.type === 'completed' && !hasPrimary) localAppProjectionError('missing primary text output'); + terminal = event.type === 'completed' || event.type === 'failed'; + yield event; + } + if (!terminal && !canceled) localAppProjectionError('text-turn missing terminal event'); + } finally { await cancel(); } }, - ); + cancel, + }); }, }), scenario: Object.freeze({ @@ -679,59 +708,17 @@ export function createNimiLocalAppRuntimeScenarioJobClient( return Object.freeze(client); } -function validateTextTurnInput(input: NimiLocalAppTextTurnInput): NimiLocalAppTextTurnInput { - assertExactKeys(input, [ - 'messages', 'temperature', 'topP', 'maxTokens', 'topK', - 'presencePenalty', 'frequencyPenalty', 'stop', 'seed', - ], 'text-turn input'); - assertNoAuthorityMaterial(input); - if (!Array.isArray(input.messages) || input.messages.length === 0 || input.messages.length > 8) { - invalidAIInput('text-turn messages are invalid'); - } - let totalBytes = 0; - let sawSystem = false; - let sawUser = false; - const messages = input.messages.map((message, index) => { - assertExactKeys(message, ['role', 'text'], `text-turn message ${index}`); - if (message.role === 'system') { - if (sawSystem || sawUser) invalidAIInput('text-turn system message order is invalid'); - sawSystem = true; - } else if (message.role === 'user') sawUser = true; - else invalidAIInput(`text-turn message ${index} role is invalid`); - const text = boundedContent(message.text, `text-turn message ${index}`, 32 * 1024); - totalBytes += utf8Length(message.role) + utf8Length(text); - if (totalBytes > 64 * 1024) invalidAIInput('text-turn prompt is too large'); - return Object.freeze({ role: message.role, text }); - }); - if (!sawUser) invalidAIInput('text-turn requires a user message'); - optionalBoundedNumber(input.temperature, 'text-turn temperature', 0, 2); - optionalBoundedNumber(input.topP, 'text-turn topP', 0, 1); - optionalBoundedInteger(input.maxTokens, 'text-turn maxTokens', 0, 4096); - optionalBoundedInteger(input.topK, 'text-turn topK', 0, Number.MAX_SAFE_INTEGER); - optionalBoundedNumber(input.presencePenalty, 'text-turn presencePenalty', -2, 2); - optionalBoundedNumber(input.frequencyPenalty, 'text-turn frequencyPenalty', -2, 2); - optionalBoundedInteger(input.seed, 'text-turn seed', Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); - if (input.stop !== undefined && (!Array.isArray(input.stop) - || input.stop.some((entry) => typeof entry !== 'string' || !entry.trim()))) invalidAIInput('text-turn stop is invalid'); - return Object.freeze({ - messages: Object.freeze(messages), - ...(input.temperature !== undefined ? { temperature: input.temperature } : {}), - ...(input.topP !== undefined ? { topP: input.topP } : {}), - ...(input.maxTokens !== undefined ? { maxTokens: input.maxTokens } : {}), - ...(input.topK !== undefined ? { topK: input.topK } : {}), - ...(input.presencePenalty !== undefined ? { presencePenalty: input.presencePenalty } : {}), - ...(input.frequencyPenalty !== undefined ? { frequencyPenalty: input.frequencyPenalty } : {}), - ...(input.stop !== undefined ? { stop: Object.freeze([...input.stop]) } : {}), - ...(input.seed !== undefined ? { seed: input.seed } : {}), - }); -} - function validateScenarioSpec( spec: T, execute: boolean, ): T { const record = asRecord(spec); if (!record || typeof record.type !== 'string') invalidAIInput('scenario spec is invalid'); + if (record.type === 'text-generate') { + if (!execute) invalidAIInput('text-generate is a single synchronous model step'); + const { type: _type, ...input } = record; + return Object.freeze({ type: 'text-generate', ...validateLocalAppTextInput(input as NimiLocalAppTextTurnInput) }) as T; + } assertNoAuthorityMaterial(record); switch (record.type) { // @nimi-authority: rule.nimi.runtime.ai-provider.face-swap-video-job @@ -969,17 +956,26 @@ function projectTextTurnEvent(value: unknown): NimiLocalAppTextTurnEvent { if (!record || !/^[1-9][0-9]*$/u.test(String(record.sequence))) localAppProjectionError('text-turn event'); const base = { sequence: String(record.sequence), traceId: boundedProjectionText(record.traceId, 'text-turn traceId', 512) }; if (record.type === 'delta') { - assertExactProjectionKeys(record, ['type', 'sequence', 'traceId', 'text'], 'text-turn delta'); - return Object.freeze({ ...base, type: 'delta', text: boundedProjectionContent(record.text, 'text-turn text', 64 * 1024) }); + assertExactProjectionKeys(record, ['type', 'sequence', 'traceId', 'text', 'itemIndex'], 'text-turn delta'); + return Object.freeze({ ...base, type: 'delta', text: boundedProjectionContent(record.text, 'text-turn text', 64 * 1024), itemIndex: projectionInteger(record.itemIndex, 'text item index', 0, 4_294_967_295) }); + } + if (record.type === 'tool-call') { + assertExactProjectionKeys(record, ['type', 'sequence', 'traceId', 'itemIndex', 'toolCall'], 'text tool call'); + return Object.freeze({ ...base, type: 'tool-call', itemIndex: projectionInteger(record.itemIndex, 'tool item index', 0, 4_294_967_295), toolCall: projectLocalAppToolCall(record.toolCall) }); + } + if (record.type === 'reasoning-continuity') { + assertExactProjectionKeys(record, ['type', 'sequence', 'traceId', 'itemIndex', 'carrier'], 'text continuity'); + return Object.freeze({ ...base, type: 'reasoning-continuity', itemIndex: projectionInteger(record.itemIndex, 'continuity item index', 0, 4_294_967_295), carrier: projectLocalAppContinuity(record.carrier) }); } if (record.type === 'completed') { assertExactProjectionKeys(record, ['type', 'sequence', 'traceId', 'finishReason'], 'text-turn completed'); - if (!['stop', 'length', 'content-filter'].includes(String(record.finishReason))) localAppProjectionError('text-turn finishReason'); + if (!['stop', 'length', 'tool-calls', 'content-filter'].includes(String(record.finishReason))) localAppProjectionError('text-turn finishReason'); return Object.freeze({ ...base, type: 'completed', finishReason: record.finishReason }) as NimiLocalAppTextTurnEvent; } if (record.type === 'failed') { - assertExactProjectionKeys(record, ['type', 'sequence', 'traceId', 'reasonCode', 'actionHint'], 'text-turn failed'); - return Object.freeze({ ...base, type: 'failed', reasonCode: boundedProjectionText(record.reasonCode, 'text-turn reasonCode', 128), actionHint: optionalProjectionText(record.actionHint, 'text-turn actionHint', 512) }); + assertExactProjectionKeys(record, ['type', 'sequence', 'traceId', 'reasonCode', 'actionHint', ...(Object.hasOwn(record, 'interruption') ? ['interruption'] : [])], 'text-turn failed'); + const interruption = projectLocalExecutionInterruption(record.interruption ?? undefined, record.reasonCode, 'failed'); + return Object.freeze({ ...base, type: 'failed', reasonCode: boundedProjectionText(record.reasonCode, 'text-turn reasonCode', 128), actionHint: optionalProjectionText(record.actionHint, 'text-turn actionHint', 512), ...(interruption ? { interruption } : {}) }); } return localAppProjectionError('text-turn event type'); } @@ -987,19 +983,27 @@ function projectTextTurnEvent(value: unknown): NimiLocalAppTextTurnEvent { function projectScenarioExecute(value: unknown): NimiLocalAppScenarioExecuteResult { const record = asRecord(value); assertExactProjectionKeys(record, ['output', 'traceId'], 'scenario execute'); - assertSafeProjection(record); const output = asRecord(record.output); if (!output) localAppProjectionError('scenario execute output'); const traceId = boundedProjectionText(record.traceId, 'scenario execute traceId', 512); + if (output.type === 'text-generate') { + assertExactProjectionKeys(output, ['type', 'items', 'finishReason'], 'text generate output'); + const items = projectLocalAppTextItems(output.items); + if (!['stop', 'length', 'tool-calls', 'content-filter'].includes(String(output.finishReason)) + || (output.finishReason === 'tool-calls' && !items.some((item) => item.type === 'tool-call'))) localAppProjectionError('text finish reason'); + return Object.freeze({ output: Object.freeze({ type: 'text-generate', items, finishReason: output.finishReason }), traceId }) as NimiLocalAppScenarioExecuteResult; + } + assertSafeProjection(record); if (output.type === 'text-embed') { - assertExactProjectionKeys(output, ['type', 'vectors'], 'text embed output'); + assertExactProjectionKeys(output, ['type', 'vectors', 'spaceId'], 'text embed output'); + const spaceId = boundedProjectionText(output.spaceId, 'embedding spaceId', 128); if (!Array.isArray(output.vectors) || output.vectors.length === 0 || output.vectors.length > 16) localAppProjectionError('text embed vectors'); const vectors = output.vectors.map((vector) => { if (!Array.isArray(vector) || vector.length === 0 || vector.length > 8192 || vector.some((entry) => typeof entry !== 'number' || !Number.isFinite(entry))) localAppProjectionError('text embed vector'); return Object.freeze([...vector] as number[]); }); - return Object.freeze({ output: Object.freeze({ type: 'text-embed', vectors: Object.freeze(vectors) }), traceId }); + return Object.freeze({ output: Object.freeze({ type: 'text-embed', vectors: Object.freeze(vectors), spaceId }), traceId }); } if (output.type === 'image-generate') { assertExactProjectionKeys(output, ['type', 'artifacts'], 'image execute output'); @@ -1205,8 +1209,26 @@ function projectRuntimeLocalAppVoiceAsset(asset: LocalAppVoiceAsset): NimiLocalA } function runtimeTextTurnRequest(input: NimiLocalAppTextTurnInput): StreamLocalAppTextTurnRequest { + const messages = toRuntimeMessages(input.messages.map((message) => ({ + role: message.role, + content: message.turnItems?.length || message.role === 'assistant' ? [] : [{ type: 'text' as const, text: message.text }], + turnItems: message.turnItems?.length ? message.turnItems.map((item) => item.type === 'output' + ? { ...item, output: localAppTextOutputToModel(item.output) } : item) : message.role === 'assistant' + ? [{ type: 'output' as const, output: { type: 'text' as const, text: message.text } }] : undefined, + }))); + const choice = input.toolChoice; + const format = input.responseFormat; return { - messages: input.messages.map((message) => ({ role: message.role, text: message.text })), + messages: messages.map((message) => ({ role: message.role, text: message.content, turnItems: message.turnItems })), + tools: toRuntimeTools(input.tools), + toolChoice: choice === 'none' ? ToolChoiceMode.NONE : choice === 'auto' ? ToolChoiceMode.AUTO + : choice === 'required' ? ToolChoiceMode.REQUIRED : choice ? ToolChoiceMode.TOOL : ToolChoiceMode.UNSPECIFIED, + toolChoiceName: typeof choice === 'object' ? choice.name : '', + responseFormat: format ? { + kind: format.type === 'json-schema' ? ResponseFormatKind.JSON_SCHEMA : format.type === 'json-object' ? ResponseFormatKind.JSON_OBJECT : ResponseFormatKind.TEXT, + jsonSchema: format.schema ? toRuntimeStruct(format.schema) : undefined, + schemaName: format.name ?? '', schemaDescription: format.description ?? '', strict: format.strict ?? false, + } : undefined, ...(input.temperature === undefined ? {} : { temperature: input.temperature }), ...(input.topP === undefined ? {} : { topP: input.topP }), ...(input.maxTokens === undefined ? {} : { maxTokens: input.maxTokens }), @@ -1219,6 +1241,7 @@ function runtimeTextTurnRequest(input: NimiLocalAppTextTurnInput): StreamLocalAp } function runtimeExecuteRequest(spec: NimiLocalAppScenarioExecuteSpec): ExecuteLocalAppScenarioRequest { + if (spec.type === 'text-generate') return { spec: { oneofKind: 'textGenerate', textGenerate: runtimeTextTurnRequest(spec) } }; if (spec.type === 'text-embed') { return { spec: { oneofKind: 'textEmbed', textEmbed: { inputs: [...spec.inputs] } } }; } @@ -1418,8 +1441,17 @@ function runtimeVoiceReference( function projectRuntimeTextTurnEvent(event: StreamLocalAppTextTurnEvent): unknown { const base = { sequence: event.sequence, traceId: event.traceId }; switch (event.payload.oneofKind) { + case 'reasoningContinuity': { + const carrier = event.payload.reasoningContinuity.carrier; + if (!carrier) return localAppProjectionError('Runtime continuity carrier'); + return { ...base, type: 'reasoning-continuity', itemIndex: event.payload.reasoningContinuity.itemIndex, + carrier: { ...carrier, payload: Array.from(carrier.payload) } }; + } + case 'toolCall': + if (!event.payload.toolCall.toolCall) return localAppProjectionError('Runtime tool call'); + return { ...base, type: 'tool-call', itemIndex: event.payload.toolCall.itemIndex, toolCall: toNimiToolCall(event.payload.toolCall.toolCall) }; case 'delta': - return { ...base, type: 'delta', text: event.payload.delta.text }; + return { ...base, type: 'delta', text: event.payload.delta.text, itemIndex: event.payload.delta.itemIndex }; case 'completed': return { ...base, @@ -1432,6 +1464,7 @@ function projectRuntimeTextTurnEvent(event: StreamLocalAppTextTurnEvent): unknow type: 'failed', reasonCode: runtimeReasonToken(event.payload.failed.reasonCode), actionHint: event.payload.failed.actionHint, + ...(event.payload.failed.interruption ? { interruption: localInterruptionFromRuntime(event.payload.failed.interruption) } : {}), }; default: return localAppProjectionError('text-turn Runtime event'); @@ -1440,11 +1473,14 @@ function projectRuntimeTextTurnEvent(event: StreamLocalAppTextTurnEvent): unknow function projectRuntimeScenarioExecuteResponse(response: ExecuteLocalAppScenarioResponse): unknown { switch (response.output.oneofKind) { + case 'textGenerate': + return { output: { type: 'text-generate', items: toNimiTextOutputItems(response.output.textGenerate.items).map(modelTextOutputToLocalApp), finishReason: runtimeFinishReason(response.output.textGenerate.finishReason) }, traceId: response.traceId }; case 'textEmbed': return { output: { type: 'text-embed', vectors: response.output.textEmbed.vectors.map((vector) => [...vector.values]), + spaceId: response.output.textEmbed.spaceId, }, traceId: response.traceId, }; @@ -1560,7 +1596,8 @@ function runtimeJobEventTypeName(value: ScenarioJobEventType): NimiLocalAppScena return types[value] ?? localAppProjectionError('scenario Runtime event type'); } -function runtimeFinishReason(value: FinishReason): 'stop' | 'length' | 'content-filter' { +function runtimeFinishReason(value: FinishReason): 'stop' | 'length' | 'tool-calls' | 'content-filter' { + if (value === FinishReason.TOOL_CALL) return 'tool-calls'; if (value === FinishReason.STOP) return 'stop'; if (value === FinishReason.LENGTH) return 'length'; if (value === FinishReason.CONTENT_FILTER) return 'content-filter'; diff --git a/sdks/typescript/core/app/local-app-runtime-platform-direct-clients.test.ts b/sdks/typescript/core/app/local-app-runtime-platform-direct-clients.test.ts index faec438797..daac5094b1 100644 --- a/sdks/typescript/core/app/local-app-runtime-platform-direct-clients.test.ts +++ b/sdks/typescript/core/app/local-app-runtime-platform-direct-clients.test.ts @@ -7,6 +7,7 @@ import { ScenarioJobEventType, ScenarioJobStatus, ScenarioType, + ToolChoiceMode, } from '../../core-generated/runtime-typed-client.js'; import { AiRealtimeAudioCodec, @@ -18,13 +19,14 @@ import { createNimiAiRealtimeRuntimeClient } from './local-app-runtime-platform- test('formal AI consumption runtime adapter keeps the canonical Local App operation family', async () => { let textRequest: unknown; let executeRequest: unknown; + let embeddingSpaceId = 'space-test-1'; const client = createNimiLocalAppAIConsumptionRuntimeClient({ streamLocalAppTextTurn(request) { textRequest = request; return (async function* () { yield { sequence: '1', traceId: 'trace-1', - payload: { oneofKind: 'delta' as const, delta: { text: 'hello' } }, + payload: { oneofKind: 'delta' as const, delta: { text: 'hello', itemIndex: 0 } }, }; yield { sequence: '2', traceId: 'trace-1', @@ -37,7 +39,7 @@ test('formal AI consumption runtime adapter keeps the canonical Local App operat return { output: { oneofKind: 'textEmbed' as const, - textEmbed: { vectors: [{ values: [0.25, 0.75] }] }, + textEmbed: { vectors: [{ values: [0.25, 0.75] }], spaceId: embeddingSpaceId }, }, traceId: 'trace-embed', }; @@ -72,21 +74,29 @@ test('formal AI consumption runtime adapter keeps the canonical Local App operat const events = []; for await (const event of stream) events.push(event); assert.deepEqual(events, [ - { type: 'delta', sequence: '1', traceId: 'trace-1', text: 'hello' }, + { type: 'delta', sequence: '1', traceId: 'trace-1', text: 'hello', itemIndex: 0 }, { type: 'completed', sequence: '2', traceId: 'trace-1', finishReason: 'stop' }, ]); assert.deepEqual(textRequest, { - messages: [{ role: 'user', text: 'hello' }], + messages: [{ role: 'user', text: 'hello', turnItems: [] }], + tools: [], + toolChoice: ToolChoiceMode.UNSPECIFIED, + toolChoiceName: '', + responseFormat: undefined, stop: [], }); assert.deepEqual(await client.scenario.execute({ type: 'text-embed', inputs: ['hello'] }), { - output: { type: 'text-embed', vectors: [[0.25, 0.75]] }, + output: { type: 'text-embed', vectors: [[0.25, 0.75]], spaceId: 'space-test-1' }, traceId: 'trace-embed', }); assert.deepEqual(executeRequest, { spec: { oneofKind: 'textEmbed', textEmbed: { inputs: ['hello'] } }, }); + embeddingSpaceId = ''; + await assert.rejects(client.scenario.execute({ type: 'text-embed', inputs: ['hello'] }), { + reasonCode: 'SDK_LOCAL_APP_PROJECTION_INVALID', + }); }); test('formal AI Realtime runtime adapter preserves typed owner input and safe projection', async () => { diff --git a/sdks/typescript/core/app/local-app-runtime-platform.test.ts b/sdks/typescript/core/app/local-app-runtime-platform.test.ts index 50442903a3..23a6025f68 100644 --- a/sdks/typescript/core/app/local-app-runtime-platform.test.ts +++ b/sdks/typescript/core/app/local-app-runtime-platform.test.ts @@ -912,8 +912,8 @@ test('Local App text stream preserves whitespace-bearing deltas as content', asy streamTurn: async () => ({ events: { async *[Symbol.asyncIterator]() { - yield { type: 'delta', sequence: '1', traceId: 'trace-text', text: 'hello ' }; - yield { type: 'delta', sequence: '2', traceId: 'trace-text', text: '\nworld' }; + yield { type: 'delta', sequence: '1', traceId: 'trace-text', text: 'hello ', itemIndex: 0 }; + yield { type: 'delta', sequence: '2', traceId: 'trace-text', text: '\nworld', itemIndex: 0 }; yield { type: 'completed', sequence: '3', traceId: 'trace-text', finishReason: 'stop' }; }, }, @@ -927,7 +927,7 @@ test('Local App text stream preserves whitespace-bearing deltas as content', asy }); const events = []; for await (const event of subscription) events.push(event); - assert.deepEqual(events.map((event) => event.type === 'delta' ? event.text : event.finishReason), [ + assert.deepEqual(events.map((event) => event.type === 'delta' ? event.text : event.type === 'completed' ? event.finishReason : event.type), [ 'hello ', '\nworld', 'stop', ]); }); diff --git a/sdks/typescript/core/app/local-app-runtime-platform.ts b/sdks/typescript/core/app/local-app-runtime-platform.ts index d544b142ff..63c03fcb95 100644 --- a/sdks/typescript/core/app/local-app-runtime-platform.ts +++ b/sdks/typescript/core/app/local-app-runtime-platform.ts @@ -1,4 +1,6 @@ import type { JsonValue } from '../../types'; +import type { NimiLocalAppTextTurnInput } from './local-app-text.js'; +export type { NimiLocalAppTextTurnInput, NimiLocalAppTextMessage, NimiLocalAppTextTurnItem, NimiLocalAppTextOutputItem, NimiLocalAppFunctionTool, NimiLocalAppToolCall } from './local-app-text.js'; import { createNimiVideoSessionClient, type NimiVideoSessionClient, type NimiVideoSessionShell } from './local-app-runtime-platform-video.js'; export type { NimiVideoSessionClient, NimiVideoSessionFormat, NimiVideoSessionOpened, NimiVideoSessionResult, NimiVideoSessionScope, NimiVideoSessionShell } from './local-app-runtime-platform-video.js'; import type { RealmModel } from '../../realm/generated.js'; @@ -410,14 +412,6 @@ export type NimiLocalAppTextCandidateInput = { readonly maxTokens?: number; }; -export type NimiLocalAppTextTurnInput = NimiLocalAppTextCandidateInput & { - readonly topK?: number; - readonly presencePenalty?: number; - readonly frequencyPenalty?: number; - readonly stop?: readonly string[]; - readonly seed?: number; -}; - export type NimiLocalAppTextCandidateResult = { readonly text: string; readonly finishReason: 'stop' | 'length' | 'content-filter'; diff --git a/sdks/typescript/core/app/local-app-text.ts b/sdks/typescript/core/app/local-app-text.ts new file mode 100644 index 0000000000..ac2085208b --- /dev/null +++ b/sdks/typescript/core/app/local-app-text.ts @@ -0,0 +1,225 @@ +import type { NimiResponseFormat, NimiGenerateTextRequest } from '../ai'; +import type { NimiFunctionTool, NimiJsonValue, NimiTextOutputItem, NimiToolCall, NimiToolResult } from '../contracts'; +import { asRecord, assertExactKeys, assertExactProjectionKeys, localAppError, localAppProjectionError } from './local-app-runtime-platform-validation'; + +export type NimiLocalAppFunctionTool = Pick; +export type NimiLocalAppToolCall = Pick; +// The native/IPC boundary is JSON; the common model uses Uint8Array. +export type NimiLocalAppReasoningContinuityCarrier = { + readonly kind: string; + readonly version: number; + readonly payload: readonly number[]; +}; +export type NimiLocalAppTextOutputItem = + | Extract + | { readonly type: 'reasoning-continuity'; readonly carrier: NimiLocalAppReasoningContinuityCarrier } + | { readonly type: 'tool-call'; readonly toolCall: NimiLocalAppToolCall }; +export type NimiLocalAppTextTurnItem = + | { readonly type: 'output'; readonly output: NimiLocalAppTextOutputItem } + | { readonly type: 'tool-result'; readonly toolResult: Pick }; +export type NimiLocalAppTextMessage = { + readonly role: 'system' | 'user' | 'assistant'; + readonly text: string; + readonly turnItems?: readonly NimiLocalAppTextTurnItem[]; +}; +export type NimiLocalAppTextTurnInput = { + readonly messages: readonly NimiLocalAppTextMessage[]; + readonly tools?: readonly NimiLocalAppFunctionTool[]; + readonly toolChoice?: NimiGenerateTextRequest['toolChoice']; + readonly responseFormat?: NimiResponseFormat; + readonly temperature?: number; + readonly topP?: number; + readonly maxTokens?: number; + readonly topK?: number; + readonly presencePenalty?: number; + readonly frequencyPenalty?: number; + readonly stop?: readonly string[]; + readonly seed?: number; +}; + +function invalid(detail: string): never { + return localAppError(`Local App text input is invalid: ${detail}.`, 'SDK_LOCAL_APP_INPUT_INVALID', 'provide_admitted_local_app_text_input'); +} + +function identifier(value: unknown, fail: (detail: string) => never): string { + if (typeof value !== 'string' || !value || value.trim() !== value || new TextEncoder().encode(value).byteLength > 128 || /[\u0000-\u001f\u007f]/u.test(value)) fail('tool identifier'); + return value as string; +} + +function jsonValue(value: unknown, fail: (detail: string) => never, ancestors = new Set(), depth = 0): asserts value is NimiJsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return; + if (typeof value === 'number' && Number.isFinite(value)) return; + if (typeof value !== 'object' || !value || depth > 32 || ancestors.has(value)) fail('JSON value'); + if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) fail('JSON object'); + ancestors.add(value as object); + for (const entry of Object.values(value as object)) jsonValue(entry, fail, ancestors, depth + 1); + ancestors.delete(value as object); +} + +function readToolCall(value: unknown, projection: boolean): NimiLocalAppToolCall { + const fail = projection ? localAppProjectionError : invalid; + const record = asRecord(value); + if (!record) fail('tool call'); + if (projection) assertExactProjectionKeys(record, ['id', 'name', 'arguments'], 'tool call'); + else assertExactKeys(record, ['id', 'name', 'arguments'], 'tool call'); + const id = identifier(record.id, fail); + const name = identifier(record.name, fail); + if (!asRecord(record.arguments) || Array.isArray(record.arguments)) fail('tool arguments object'); + jsonValue(record.arguments, fail); + return Object.freeze({ id, name, arguments: record.arguments as NimiJsonValue }); +} + +export function projectLocalAppToolCall(value: unknown): NimiLocalAppToolCall { + return readToolCall(value, true); +} + +function readContinuity(value: unknown, projection: boolean): NimiLocalAppReasoningContinuityCarrier { + const fail = projection ? localAppProjectionError : invalid; + const record = asRecord(value); + if (!record) return fail('continuity carrier'); + if (projection) assertExactProjectionKeys(record, ['kind', 'version', 'payload'], 'continuity carrier'); + else assertExactKeys(record, ['kind', 'version', 'payload'], 'continuity carrier'); + const kind = identifier(record.kind, fail); + if (!Number.isSafeInteger(record.version) || Number(record.version) < 1 || Number(record.version) > 0xffff_ffff + || !Array.isArray(record.payload) || record.payload.length === 0 || record.payload.length > 64 * 1024 + || record.payload.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255)) return fail('continuity payload'); + return Object.freeze({ kind, version: Number(record.version), payload: Object.freeze([...record.payload]) }); +} + +export function projectLocalAppContinuity(value: unknown): NimiLocalAppReasoningContinuityCarrier { + return readContinuity(value, true); +} + +export function modelTextOutputToLocalApp(item: NimiTextOutputItem): NimiLocalAppTextOutputItem { + if (item.type !== 'reasoning-continuity') return item as NimiLocalAppTextOutputItem; + if (!(item.carrier?.payload instanceof Uint8Array)) invalid('continuity payload'); + return { ...item, carrier: { ...item.carrier, payload: Array.from(item.carrier.payload) } }; +} + +export function localAppTextOutputToModel(item: NimiLocalAppTextOutputItem): NimiTextOutputItem { + return item.type === 'reasoning-continuity' + ? { ...item, carrier: { ...item.carrier, payload: new Uint8Array(item.carrier.payload) } } : item; +} + +function readOutputItem(value: unknown, projection: boolean): NimiLocalAppTextOutputItem { + const fail = projection ? localAppProjectionError : invalid; + const record = asRecord(value); + if (!record) return fail('text output item'); + const keys = record.type === 'text' ? ['type', 'text'] : record.type === 'reasoning-continuity' ? ['type', 'carrier'] : ['type', 'toolCall']; + if (projection) assertExactProjectionKeys(record, keys, 'text output item'); + else assertExactKeys(record, keys, 'text output item'); + if (record.type === 'text' && typeof record.text === 'string' && record.text.length > 0) { + if (projection && new TextEncoder().encode(record.text).byteLength > 256 * 1024) return fail('text item size'); + return Object.freeze({ type: 'text', text: record.text }); + } + if (record.type === 'tool-call') return Object.freeze({ type: 'tool-call', toolCall: readToolCall(record.toolCall, projection) }); + if (record.type === 'reasoning-continuity') return Object.freeze({ type: 'reasoning-continuity', carrier: readContinuity(record.carrier, projection) }); + return fail('unsupported text output item'); +} + +export function projectLocalAppTextItems(value: unknown): readonly NimiLocalAppTextOutputItem[] { + if (!Array.isArray(value) || value.length === 0) localAppProjectionError('text output items'); + const ids = new Set(); + const items = value.map((item) => { + const projected = readOutputItem(item, true); + if (projected.type === 'tool-call') { + if (ids.has(projected.toolCall.id)) localAppProjectionError('duplicate tool call'); + ids.add(projected.toolCall.id); + } + return projected; + }); + if (!items.some((item) => item.type === 'text' || item.type === 'tool-call')) localAppProjectionError('missing primary text output'); + // Runtime and the native carrier enforce the total on the original protobuf. + // Parsed tool arguments no longer retain its JSON spelling or encoded size. + return Object.freeze(items); +} + +// Arbitrary schema and result keys are business JSON, never identity selectors. +// Only the surrounding exact typed envelopes carry protocol fields. +// @nimi-authority: rule.nimi.sdks.feature-clients.local-app-text-behaviors +export function validateLocalAppTextInput(value: unknown): NimiLocalAppTextTurnInput { + assertExactKeys(value, ['messages', 'tools', 'toolChoice', 'responseFormat', 'temperature', 'topP', 'maxTokens', 'topK', 'presencePenalty', 'frequencyPenalty', 'stop', 'seed'], 'text-turn input'); + const input = value as unknown as NimiLocalAppTextTurnInput; + if (!Array.isArray(input.messages) || input.messages.length === 0 || input.messages.length > 128) invalid('messages'); + if (input.tools !== undefined && (!Array.isArray(input.tools) || input.tools.length > 64)) invalid('tools'); + const tools = new Set(); + for (const tool of input.tools ?? []) { + assertExactKeys(tool, ['type', 'name', 'description', 'inputSchema'], 'function tool'); + const name = identifier(tool.name, invalid); + if (tools.has(name) || (tool.type !== undefined && tool.type !== 'function') || !asRecord(tool.inputSchema) + || Array.isArray(tool.inputSchema) || (tool.description !== undefined && typeof tool.description !== 'string')) invalid('function tool'); + jsonValue(tool.inputSchema, invalid); + tools.add(name); + } + const calls = new Map(); + let sawUser = false; + const messages = input.messages.map((message, index): NimiLocalAppTextMessage => { + assertExactKeys(message, ['role', 'text', 'turnItems'], 'text message'); + const role = message.role; + if (role !== 'system' && role !== 'user' && role !== 'assistant') invalid('message role'); + if (role === 'system' && index !== 0) invalid('message role'); + if (typeof message.text !== 'string') invalid('message text'); + if (message.role === 'user') sawUser = true; + if (message.turnItems !== undefined && !Array.isArray(message.turnItems)) invalid('ordered transcript'); + if (!message.turnItems?.length) { + if (!message.text.trim()) invalid('empty message'); + return Object.freeze({ role, text: message.text }); + } + if (message.role !== 'assistant' || message.text !== '') invalid('mixed transcript content'); + const turnItems = message.turnItems.map((item): NimiLocalAppTextTurnItem => { + if (!asRecord(item)) invalid('turn item'); + if (item.type === 'output') { + assertExactKeys(item, ['type', 'output'], 'output turn item'); + const output = readOutputItem(item.output, false); + if (output.type === 'tool-call') { + if (!tools.has(output.toolCall.name) || calls.has(output.toolCall.id)) invalid('undeclared or duplicate tool call'); + calls.set(output.toolCall.id, output.toolCall.name); + } + return Object.freeze({ type: 'output', output }); + } + if (item.type !== 'tool-result') invalid('turn item'); + assertExactKeys(item, ['type', 'toolResult'], 'tool result turn item'); + const result = item.toolResult; + assertExactKeys(result, ['toolCallId', 'toolName', 'result', 'isError'], 'tool result'); + const toolCallId = identifier(result.toolCallId, invalid); + const toolName = identifier(result.toolName, invalid); + if (calls.get(toolCallId) !== toolName) invalid('tool result association'); + if (result.isError !== undefined && typeof result.isError !== 'boolean') invalid('tool result status'); + jsonValue(result.result, invalid); + return Object.freeze({ type: 'tool-result', toolResult: Object.freeze({ toolCallId, toolName, result: result.result, ...(result.isError === undefined ? {} : { isError: result.isError }) }) }); + }); + return Object.freeze({ role: 'assistant', text: '', turnItems: Object.freeze(turnItems) }); + }); + if (!sawUser) invalid('user message required'); + const choice = input.toolChoice; + if (typeof choice === 'object' && choice !== null) { + assertExactKeys(choice, ['type', 'name'], 'tool choice'); + if (choice.type !== 'tool' || !tools.has(choice.name)) invalid('named tool choice'); + } else if (choice !== undefined && (!['auto', 'none', 'required'].includes(choice) || (choice !== 'none' && tools.size === 0))) invalid('tool choice'); + if (input.responseFormat !== undefined) { + const format = input.responseFormat; + assertExactKeys(format, ['type', 'schema', 'name', 'description', 'strict'], 'response format'); + if (!['text', 'json-object', 'json-schema'].includes(format.type) + || (format.name !== undefined && typeof format.name !== 'string') + || (format.description !== undefined && typeof format.description !== 'string') + || (format.strict !== undefined && typeof format.strict !== 'boolean')) invalid('response format'); + if (format.type === 'json-schema') { + if (!asRecord(format.schema) || Array.isArray(format.schema)) invalid('response schema'); + jsonValue(format.schema, invalid); + } else if (format.schema !== undefined || format.strict) invalid('response schema mode'); + } + const bounds: readonly [unknown, number, number, boolean][] = [ + [input.temperature, 0, 2, false], [input.topP, 0, 1, false], + [input.maxTokens, 0, 2_147_483_647, true], [input.topK, 0, 2_147_483_647, true], + [input.presencePenalty, -2, 2, false], [input.frequencyPenalty, -2, 2, false], + [input.seed, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, true], + ]; + for (const [value, minimum, maximum, integer] of bounds) { + if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value < minimum || value > maximum || (integer && !Number.isSafeInteger(value)))) invalid('sampling parameter'); + } + if (input.stop !== undefined && (!Array.isArray(input.stop) || input.stop.some((s) => typeof s !== 'string' || !s.trim()))) invalid('stop'); + const normalized = { ...input, messages }; + const serialized = JSON.stringify(normalized); + if (new TextEncoder().encode(serialized).byteLength > 1024 * 1024) invalid('request size'); + return Object.freeze(JSON.parse(serialized) as NimiLocalAppTextTurnInput); +} diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 05c0d1a8d3..dab1bc29b1 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@nimiplatform/sdk", - "version": "0.11.0", + "version": "0.12.0", "description": "TypeScript SDK for Nimi apps and integrations.", "private": false, "license": "Apache-2.0", diff --git a/sdks/typescript/runtime/wire-types/ai-scenario-types.ts b/sdks/typescript/runtime/wire-types/ai-scenario-types.ts index 6876b9257b..d3f240b2f3 100644 --- a/sdks/typescript/runtime/wire-types/ai-scenario-types.ts +++ b/sdks/typescript/runtime/wire-types/ai-scenario-types.ts @@ -54,9 +54,12 @@ import type { LocalAppTextCandidateMessage, LocalAppTextEmbedOutput, LocalAppTextEmbedScenarioSpec, + LocalAppTextGenerateOutput, LocalAppTextTurnCompleted, + LocalAppTextTurnContinuity, LocalAppTextTurnDelta, LocalAppTextTurnFailed, + LocalAppTextTurnToolCall, LocalAppVideoGenerateJobSpec, LocalAppVideoGenerationOptions, LocalAppVoiceAsset, @@ -207,9 +210,12 @@ export type { LocalAppTextCandidateMessage, LocalAppTextEmbedOutput, LocalAppTextEmbedScenarioSpec, + LocalAppTextGenerateOutput, LocalAppTextTurnCompleted, + LocalAppTextTurnContinuity, LocalAppTextTurnDelta, LocalAppTextTurnFailed, + LocalAppTextTurnToolCall, LocalAppVideoGenerateJobSpec, LocalAppVideoGenerationOptions, LocalAppVoiceAsset,