diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 972388451b..8e44ba4b78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,8 @@ catalogs: overrides: vite@*: npm:@voidzero-dev/vite-plus-core@1.4.0 vitest@*: 5.0.5 + glob: ^14.0.2 + minimatch: ^11.0.1 importers: @@ -436,6 +438,9 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@isaacs/nonrecursive-glob-match@1.0.3': + resolution: {integrity: sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2489,8 +2494,8 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@13.0.6: - resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + glob@14.0.3: + resolution: {integrity: sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==} engines: {node: 18 || 20 || >=22} gopd@1.2.0: @@ -2831,8 +2836,8 @@ packages: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true - minimatch@10.2.6: - resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + minimatch@11.0.3: + resolution: {integrity: sha512-iny/40PEGkNJQ5/Pv6z3nm6FbRAby32GeZRjCUJk2WnNDbgPSTcdI+DDvLqsgBdD4cD5sMtMheqPPnehCCa8Nw==} engines: {node: 18 || 20 || >=22} minipass@7.1.3: @@ -2940,8 +2945,8 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + path-scurry@3.0.0: + resolution: {integrity: sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==} engines: {node: 18 || 20 || >=22} pathe@2.0.3: @@ -3572,6 +3577,8 @@ snapshots: '@floating-ui/utils@0.2.12': {} + '@isaacs/nonrecursive-glob-match@1.0.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -5270,7 +5277,7 @@ snapshots: dependencies: '@one-ini/wasm': 0.2.1 commander: 14.0.3 - minimatch: 10.2.6 + minimatch: 11.0.3 semver: 7.8.5 electron-to-chromium@1.5.463: {} @@ -5382,11 +5389,11 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@13.0.6: + glob@14.0.3: dependencies: - minimatch: 10.2.6 + minimatch: 11.0.3 minipass: 7.1.3 - path-scurry: 2.0.2 + path-scurry: 3.0.0 gopd@1.2.0: {} @@ -5480,7 +5487,7 @@ snapshots: dependencies: config-chain: 1.1.13 editorconfig: 3.0.2 - glob: 13.0.6 + glob: 14.0.3 js-cookie: 3.0.8 nopt: 10.0.1 @@ -5695,8 +5702,9 @@ snapshots: mini-svg-data-uri@1.4.4: {} - minimatch@10.2.6: + minimatch@11.0.3: dependencies: + '@isaacs/nonrecursive-glob-match': 1.0.3 brace-expansion: 5.0.12 minipass@7.1.3: {} @@ -5866,7 +5874,7 @@ snapshots: path-parse@1.0.7: {} - path-scurry@2.0.2: + path-scurry@3.0.0: dependencies: lru-cache: 11.5.3 minipass: 7.1.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ad8e8b9649..21a7f02d15 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,12 +16,16 @@ allowBuilds: overrides: 'vite@*': npm:@voidzero-dev/vite-plus-core@1.4.0 'vitest@*': 5.0.5 + # js-beautify, used by @vue/test-utils, still requires glob and minimatch + # versions affected by GHSA-3g3m-2fjr-6pw4 and GHSA-g3hv-75wg-m249. + glob: ^14.0.2 + minimatch: ^11.0.1 -# Supply-chain defense: require packages to be at least 7 days old before +# Supply-chain defense: require packages to be at least 3 days old before # install. Compromised packages typically get yanked within hours, so this # buffer dramatically reduces the chance of pulling a malicious release. -# Value is in minutes (7 days × 24h × 60min = 10080). -minimumReleaseAge: 10080 +# Value is in minutes (3 days × 24h × 60min = 4320). +minimumReleaseAge: 4320 catalog: '@popperjs/core': ^2.11.8 '@tailwindcss/vite': ^4.3.3 diff --git a/src/ai/README.md b/src/ai/README.md index 4db416d956..61aba830ba 100644 --- a/src/ai/README.md +++ b/src/ai/README.md @@ -27,6 +27,8 @@ Skill discovery is cached for the fixed paths in `ai.skills.cached_paths`. [Relo The conversation migrations use Hypervel's column types, indexes, and approval claim fields. Existing Laravel conversation data requires a [schema migration](https://hypervel.org/docs/ai-sdk#migrating-conversation-data) before use. +Tool, sub-agent, and file storage tool failures include the exception message in the model's tool result only when `app.debug` is enabled. Otherwise the model receives a generic result such as `The tool call failed.`, because exception messages can carry query bindings, server paths, and other private details to the provider, the conversation, and the client. Exceptions recovered this way are reported in both modes. Return a string from the tool when the model should know why an expected failure occurred. See [validating tool arguments](https://hypervel.org/docs/ai-sdk#validating-tool-arguments). + Remote downloads share the HTTP client's destination checks. The protected `UntrustedUrl::validate()`, `resolve()`, and `isBlocked()` helpers and hostname-blocklist constants are removed; `resolveUsing()` remains available. URLs containing embedded credentials are rejected. Downloads are limited to 32 MiB of decoded content by default, instead of Laravel's 100 MB; set `ai.remote_files.max_size` in kilobytes to change the limit, or to `null` to disable it. See [remote attachments](https://hypervel.org/docs/ai-sdk#remote-attachments). ## Known Issues diff --git a/src/ai/src/Classification/Boolean.php b/src/ai/src/Classification/Boolean.php index a3db06e989..22b3eda9e5 100644 --- a/src/ai/src/Classification/Boolean.php +++ b/src/ai/src/Classification/Boolean.php @@ -12,8 +12,8 @@ /** * Create a new yes / no question whose answer is the probability of "true". * - * @param array|string $instructions - * @param null|array{true?: string, false?: string} $criteria descriptions of what a yes and a no mean + * @param array|string $instructions + * @param null|array{true?: array|string, false?: array|string} $criteria descriptions of what a yes and a no mean * * @throws InvalidArgumentException if the criteria describe anything but the true and false cases */ diff --git a/src/ai/src/Classification/Choice.php b/src/ai/src/Classification/Choice.php index 576596b2b6..39f2dc6082 100644 --- a/src/ai/src/Classification/Choice.php +++ b/src/ai/src/Classification/Choice.php @@ -12,8 +12,8 @@ /** * Create a new single-choice question whose answer is one of the given options. * - * @param array|string $instructions - * @param array|string> $options option names mapped to an optional description + * @param array|string $instructions + * @param array|string> $options option names mapped to an optional description * * @throws InvalidArgumentException if fewer than two options are given */ diff --git a/src/ai/src/Classification/CollectionChoice.php b/src/ai/src/Classification/CollectionChoice.php index 6cee4aaba7..81c756b3f1 100644 --- a/src/ai/src/Classification/CollectionChoice.php +++ b/src/ai/src/Classification/CollectionChoice.php @@ -20,7 +20,7 @@ final class CollectionChoice /** * The option names mapped to their descriptions. * - * @var array|string> + * @var array|string> */ private array $options = []; @@ -65,7 +65,7 @@ public function __construct( /** * Choose the item that best answers the question about the given text. * - * @param array|string $text + * @param array|string $text * @param null|float $threshold the minimum probability of the chosen option; below it, null is returned * * @throws InvalidArgumentException if fewer than two options are available @@ -120,7 +120,7 @@ private function resolveLabel(mixed $item, Closure|string|null $by): string * Resolve the option description for the given item. * * @param null|array|Closure|string $describe - * @return null|array|string + * @return null|array|string */ private function resolveDescription(mixed $item, Closure|array|string|null $describe): string|array|null { diff --git a/src/ai/src/Classification/Score.php b/src/ai/src/Classification/Score.php index fa3b0aad31..ab80b47efa 100644 --- a/src/ai/src/Classification/Score.php +++ b/src/ai/src/Classification/Score.php @@ -12,8 +12,8 @@ /** * Create a new ordinal question whose answer is the expected position on the given levels. * - * @param array|string $instructions - * @param list|string> $levels level descriptions ordered from lowest to highest + * @param array|string $instructions + * @param list|string> $levels level descriptions ordered from lowest to highest * * @throws InvalidArgumentException if the levels are not a list of at least two entries */ diff --git a/src/ai/src/Exceptions/ToolExecutionException.php b/src/ai/src/Exceptions/ToolExecutionException.php new file mode 100644 index 0000000000..89966ab5a6 --- /dev/null +++ b/src/ai/src/Exceptions/ToolExecutionException.php @@ -0,0 +1,21 @@ +getMessage(), previous: $exception); + } +} diff --git a/src/ai/src/Gateway/Anthropic/AnthropicGateway.php b/src/ai/src/Gateway/Anthropic/AnthropicGateway.php index af2a990f06..2c417f4623 100644 --- a/src/ai/src/Gateway/Anthropic/AnthropicGateway.php +++ b/src/ai/src/Gateway/Anthropic/AnthropicGateway.php @@ -129,6 +129,7 @@ public function generateStreamStep( $provider, $model, $response->getBody(), + $this->pausedContent($messages), ); } diff --git a/src/ai/src/Gateway/Anthropic/Concerns/HandlesTextStreaming.php b/src/ai/src/Gateway/Anthropic/Concerns/HandlesTextStreaming.php index 74b1a903cf..4b7f7a7711 100644 --- a/src/ai/src/Gateway/Anthropic/Concerns/HandlesTextStreaming.php +++ b/src/ai/src/Gateway/Anthropic/Concerns/HandlesTextStreaming.php @@ -5,6 +5,8 @@ namespace Hypervel\Ai\Gateway\Anthropic\Concerns; use Generator; +use Hypervel\Ai\Messages\AssistantMessage; +use Hypervel\Ai\Messages\Message; use Hypervel\Ai\Providers\Provider; use Hypervel\Ai\Responses\Data\TextUsage; use Hypervel\Ai\Responses\Data\ToolCall; @@ -21,19 +23,40 @@ use Hypervel\Ai\Streaming\Events\TextEnd; use Hypervel\Ai\Streaming\Events\TextStart; use Hypervel\Ai\Streaming\Events\ToolCall as ToolCallEvent; +use Hypervel\Support\Collection; use Hypervel\Support\Str; use Psr\Http\Message\StreamInterface; trait HandlesTextStreaming { + /** + * Get the content of the paused steps the turn being continued is made of. + * + * @param array $messages + * @return array> + */ + protected function pausedContent(array $messages): array + { + /** @var Collection $paused */ + $paused = collect($messages) + ->reverse() + ->takeWhile(fn (Message $message): bool => $message instanceof AssistantMessage) + ->reverse(); + + return $paused->flatMap(fn (AssistantMessage $message): array => $message->replayBlocks)->all(); + } + /** * Process a single Anthropic streaming turn and yield Hypervel stream events. + * + * @param array> $pausedContent */ protected function processTextStream( string $invocationId, Provider $provider, string $model, StreamInterface $streamBody, + array $pausedContent = [], ): Generator { $messageId = $this->generateEventId(); $reasoningId = ''; @@ -53,6 +76,18 @@ protected function processTextStream( $afterTool = false; $lastTextDelta = ''; + // A turn resumed after pause_turn picks up where the paused content ended... + foreach ($pausedContent as $block) { + $blockType = $block['type'] ?? ''; + + if ($blockType === 'text' && ($block['text'] ?? '') !== '') { + $lastTextDelta = (string) $block['text']; + $afterTool = false; + } elseif ($this->isToolBlock($blockType)) { + $afterTool = true; + } + } + $messageUsage = []; $usage = null; $stopReason = ''; diff --git a/src/ai/src/Gateway/Anthropic/Concerns/ParsesTextResponses.php b/src/ai/src/Gateway/Anthropic/Concerns/ParsesTextResponses.php index 4e39170f33..245244d154 100644 --- a/src/ai/src/Gateway/Anthropic/Concerns/ParsesTextResponses.php +++ b/src/ai/src/Gateway/Anthropic/Concerns/ParsesTextResponses.php @@ -89,6 +89,11 @@ protected function buildStepResponse( $finishReason = FinishReason::Stop; } + // Claude can end a message holding tool_use blocks with end_turn, so the tool calls still need to run. + if ($finishReason === FinishReason::Stop && $realToolCalls !== []) { + $finishReason = FinishReason::ToolCalls; + } + return new StepResponse( text: $hasStructuredToolCall && ! empty($structuredData) ? (json_encode($structuredData) ?: '') : $text, toolCalls: $realToolCalls, diff --git a/src/ai/src/Gateway/AzureOpenAi/AzureOpenAiGateway.php b/src/ai/src/Gateway/AzureOpenAi/AzureOpenAiGateway.php index 57c025d9eb..129fdb858b 100644 --- a/src/ai/src/Gateway/AzureOpenAi/AzureOpenAiGateway.php +++ b/src/ai/src/Gateway/AzureOpenAi/AzureOpenAiGateway.php @@ -81,11 +81,11 @@ public function generateEmbeddings( $data = $response->json(); - return new EmbeddingsResponse( + return (new EmbeddingsResponse( $this->parseIndexedEmbeddings($data['data'] ?? [], count($inputs), $provider->name()), new Usage($data['usage']['prompt_tokens'] ?? 0), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** @@ -122,14 +122,14 @@ public function generateImage( $data = $response->json(); - return new ImageResponse( + return (new ImageResponse( collect($data['data'] ?? [])->map(fn (array $image): GeneratedImage => new GeneratedImage( $image['b64_json'] ?? '', 'image/' . ($data['output_format'] ?? 'png'), )), $this->extractImageUsage($data), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** diff --git a/src/ai/src/Gateway/Bedrock/BedrockTextGateway.php b/src/ai/src/Gateway/Bedrock/BedrockTextGateway.php index 1c174efa58..14037f6f41 100644 --- a/src/ai/src/Gateway/Bedrock/BedrockTextGateway.php +++ b/src/ai/src/Gateway/Bedrock/BedrockTextGateway.php @@ -260,6 +260,11 @@ protected function parseTextResponse(array $result, TextProvider $provider, stri $finishReason = FinishReason::Stop; } + // Like the Anthropic API, Converse can end a message holding toolUse blocks with end_turn, so the tool calls still need to run. + if ($finishReason === FinishReason::Stop && $toolCalls !== []) { + $finishReason = FinishReason::ToolCalls; + } + return new StepResponse( text: $structuredOutput ?? $output, toolCalls: $toolCalls, @@ -564,6 +569,11 @@ protected function processTextStream( $finishReason = FinishReason::Stop; } + // Like the Anthropic API, Converse can end a message holding toolUse blocks with end_turn, so the tool calls still need to run. + if ($finishReason === FinishReason::Stop && $toolCalls !== []) { + $finishReason = FinishReason::ToolCalls; + } + $replayBlocks = array_values($responseContent); if (! $hasReasoningBlocks) { diff --git a/src/ai/src/Gateway/CohereGateway.php b/src/ai/src/Gateway/CohereGateway.php index 9786fb93a1..a263421aef 100644 --- a/src/ai/src/Gateway/CohereGateway.php +++ b/src/ai/src/Gateway/CohereGateway.php @@ -138,11 +138,11 @@ public function generateEmbeddings( $data = $response->json(); - return new EmbeddingsResponse( + return (new EmbeddingsResponse( $this->parseCohereEmbeddings($data['embeddings'] ?? []), new Usage($data['meta']['billed_units']['input_tokens'] ?? 0), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** @@ -179,14 +179,14 @@ public function rerank( score: $result['relevance_score'], ))->all(); - return new RerankingResponse( + return (new RerankingResponse( $results, new RerankingUsage( inputTokens: $data['meta']['billed_units']['input_tokens'] ?? 0, searchUnits: $data['meta']['billed_units']['search_units'] ?? null, ), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** diff --git a/src/ai/src/Gateway/Concerns/InvokesTools.php b/src/ai/src/Gateway/Concerns/InvokesTools.php index 65bdccf5a7..3566b13a56 100644 --- a/src/ai/src/Gateway/Concerns/InvokesTools.php +++ b/src/ai/src/Gateway/Concerns/InvokesTools.php @@ -5,11 +5,13 @@ namespace Hypervel\Ai\Gateway\Concerns; use Hypervel\Ai\Contracts\Tool; +use Hypervel\Ai\Exceptions\ToolExecutionException; use Hypervel\Ai\Gateway\ParentInvocation; use Hypervel\Ai\Gateway\RunContext; use Hypervel\Ai\Providers\Tools\ToolSearch; use Hypervel\Ai\Tools\Request; use Hypervel\Ai\Tools\ToolNameResolver; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Str; use Hypervel\Validation\ValidationException; use Throwable; @@ -42,7 +44,8 @@ protected function executeTool(Tool $tool, array $arguments, ?string $toolCallId } catch (Throwable $exception) { // Keep the original exception, including cancellation, as previous if failure reporting throws. try { - throw $exception; + // Unlike upstream, cancellation is not wrapped, so the caller never recovers it as the tool's own failure. + throw Coroutine::causedByCancellation($exception) ? $exception : new ToolExecutionException($exception); } finally { $context?->toolFailed($tool, $arguments, $exception, $toolInvocationId, $this->elapsedMilliseconds($startedAt)); } diff --git a/src/ai/src/Gateway/ElevenLabsGateway.php b/src/ai/src/Gateway/ElevenLabsGateway.php index e1709d4fd2..3448551183 100644 --- a/src/ai/src/Gateway/ElevenLabsGateway.php +++ b/src/ai/src/Gateway/ElevenLabsGateway.php @@ -86,14 +86,14 @@ public function generateTranscription( 'diarize' => $diarize ? 'true' : 'false', ])))->throw()); - $response = $response->json(); + $data = $response->json(); $segments = $diarize - ? ($response['words'] ?? []) + ? ($data['words'] ?? []) : []; - return new TranscriptionResponse( - $response['text'], + return (new TranscriptionResponse( + $data['text'], (new Collection($segments))->map(function (array $segment): ?TranscriptionSegment { if ($segment['type'] !== 'word') { return null; @@ -106,9 +106,9 @@ public function generateTranscription( $segment['end'], ); })->filter()->values(), - new TranscriptionUsage(audioSeconds: $response['audio_duration_secs'] ?? null), + new TranscriptionUsage(audioSeconds: $data['audio_duration_secs'] ?? null), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** diff --git a/src/ai/src/Gateway/FakeClassificationGateway.php b/src/ai/src/Gateway/FakeClassificationGateway.php index 16e5738222..ae999d50d7 100644 --- a/src/ai/src/Gateway/FakeClassificationGateway.php +++ b/src/ai/src/Gateway/FakeClassificationGateway.php @@ -154,7 +154,7 @@ protected function fakeChoiceAnswer(array $options): ChoiceAnswer /** * Generate a fake score answer. * - * @param list|string> $levels + * @param list|string> $levels */ protected function fakeScoreAnswer(array $levels): ScoreAnswer { diff --git a/src/ai/src/Gateway/Gemini/GeminiGateway.php b/src/ai/src/Gateway/Gemini/GeminiGateway.php index 8af465e21d..23dbc16cb3 100644 --- a/src/ai/src/Gateway/Gemini/GeminiGateway.php +++ b/src/ai/src/Gateway/Gemini/GeminiGateway.php @@ -165,11 +165,11 @@ public function generateImage( $block['mime_type'] ?? 'image/png', )); - return new ImageResponse( + return (new ImageResponse( $images, $this->extractImageUsage($data), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** @@ -213,11 +213,11 @@ public function generateEmbeddings( $data = $response->json(); - return new EmbeddingsResponse( + return (new EmbeddingsResponse( (new Collection($data['embeddings'] ?? []))->pluck('values')->all(), new Usage($data['usageMetadata']['promptTokenCount'] ?? 0), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** @@ -357,12 +357,12 @@ protected function transcribeWithConfig( $steps = $data['steps'] ?? []; - return new TranscriptionResponse( + return (new TranscriptionResponse( trim($this->extractText($steps)), $this->speakerSegments($steps), TranscriptionUsage::from($this->extractUsage($data)), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** @@ -504,12 +504,12 @@ protected function transcribeWithPrompt( $segments = new Collection; } - return new TranscriptionResponse( + return (new TranscriptionResponse( trim((string) $text), $segments, TranscriptionUsage::from($this->extractUsage($payload)), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** diff --git a/src/ai/src/Gateway/Groq/GroqGateway.php b/src/ai/src/Gateway/Groq/GroqGateway.php index 61efb891ae..64e707d3f5 100644 --- a/src/ai/src/Gateway/Groq/GroqGateway.php +++ b/src/ai/src/Gateway/Groq/GroqGateway.php @@ -136,7 +136,7 @@ public function generateTranscription( $data = $response->json(); - return new TranscriptionResponse( + return (new TranscriptionResponse( $data['text'] ?? '', collect($data['segments'] ?? [])->map(fn (array $segment): TranscriptionSegment => new TranscriptionSegment( $segment['text'] ?? '', @@ -146,6 +146,6 @@ public function generateTranscription( )), new TranscriptionUsage(audioSeconds: $data['duration'] ?? null), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } } diff --git a/src/ai/src/Gateway/JinaGateway.php b/src/ai/src/Gateway/JinaGateway.php index 9eec2398a8..300c9acd31 100644 --- a/src/ai/src/Gateway/JinaGateway.php +++ b/src/ai/src/Gateway/JinaGateway.php @@ -55,11 +55,11 @@ public function generateEmbeddings( $embeddings = (new Collection($data['data']))->pluck('embedding')->all(); - return new EmbeddingsResponse( + return (new EmbeddingsResponse( $embeddings, new Usage($data['usage']['total_tokens'] ?? 0), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** @@ -95,11 +95,11 @@ public function rerank( score: $result['relevance_score'], ))->all(); - return new RerankingResponse( + return (new RerankingResponse( $results, new RerankingUsage($data['usage']['total_tokens'] ?? 0), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** diff --git a/src/ai/src/Gateway/Mistral/MistralGateway.php b/src/ai/src/Gateway/Mistral/MistralGateway.php index 96a628f1b6..a2361515d5 100644 --- a/src/ai/src/Gateway/Mistral/MistralGateway.php +++ b/src/ai/src/Gateway/Mistral/MistralGateway.php @@ -150,11 +150,11 @@ public function generateEmbeddings( $data = $response->json(); - return new EmbeddingsResponse( + return (new EmbeddingsResponse( collect($data['data'] ?? [])->pluck('embedding')->all(), new Usage($data['usage']['prompt_tokens'] ?? 0), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** @@ -190,7 +190,7 @@ public function generateTranscription( $data = $response->json(); - return new TranscriptionResponse( + return (new TranscriptionResponse( $data['text'] ?? '', collect($data['segments'] ?? [])->map(fn (array $segment): TranscriptionSegment => new TranscriptionSegment( $segment['text'] ?? '', @@ -204,7 +204,7 @@ public function generateTranscription( audioSeconds: $data['usage']['prompt_audio_seconds'] ?? null, ), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** diff --git a/src/ai/src/Gateway/Ollama/OllamaGateway.php b/src/ai/src/Gateway/Ollama/OllamaGateway.php index 87ee02a117..f809e9f209 100644 --- a/src/ai/src/Gateway/Ollama/OllamaGateway.php +++ b/src/ai/src/Gateway/Ollama/OllamaGateway.php @@ -135,10 +135,10 @@ public function generateEmbeddings( )); } - return new EmbeddingsResponse( + return (new EmbeddingsResponse( $data['embeddings'] ?? [], new Usage($data['prompt_eval_count'] ?? 0), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } } diff --git a/src/ai/src/Gateway/OpenAi/OpenAiClassificationGateway.php b/src/ai/src/Gateway/OpenAi/OpenAiClassificationGateway.php index 4748b47a2f..72ab382eb4 100644 --- a/src/ai/src/Gateway/OpenAi/OpenAiClassificationGateway.php +++ b/src/ai/src/Gateway/OpenAi/OpenAiClassificationGateway.php @@ -168,7 +168,7 @@ protected function mapQuestion(string $name, Question $question): array /** * Map a score level to a label, keeping the description of levels given as a label and description. * - * @param array|string $level + * @param array|string $level */ protected function mapLevel(string|array $level): array { @@ -190,7 +190,7 @@ protected function predicateInstructions(Boolean $question): string $instructions = $this->toText($question->instructions); foreach ($question->criteria ?? [] as $case => $description) { - $instructions .= "\n\n" . ucfirst($case) . ": {$description}"; + $instructions .= "\n\n" . ucfirst($case) . ': ' . $this->toText($description); } return $instructions; diff --git a/src/ai/src/Gateway/OpenAi/OpenAiGateway.php b/src/ai/src/Gateway/OpenAi/OpenAiGateway.php index ffb64b4e56..a836dd15ae 100644 --- a/src/ai/src/Gateway/OpenAi/OpenAiGateway.php +++ b/src/ai/src/Gateway/OpenAi/OpenAiGateway.php @@ -93,14 +93,14 @@ public function generateImage( $data = $response->json(); - return new ImageResponse( + return (new ImageResponse( collect($data['data'] ?? [])->map(fn (array $image): GeneratedImage => new GeneratedImage( $image['b64_json'] ?? '', 'image/' . ($data['output_format'] ?? 'png'), )), $this->extractImageUsage($data), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** @@ -248,7 +248,7 @@ public function generateTranscription( $data = $response->json(); - return new TranscriptionResponse( + return (new TranscriptionResponse( $data['text'] ?? '', collect($data['segments'] ?? [])->map(fn (array $segment): TranscriptionSegment => new TranscriptionSegment( $segment['text'] ?? '', @@ -262,7 +262,7 @@ public function generateTranscription( audioSeconds: Arr::get($data, 'usage.seconds') ?? Arr::get($data, 'duration'), ), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** @@ -288,10 +288,10 @@ public function generateEmbeddings( $data = $response->json(); - return new EmbeddingsResponse( + return (new EmbeddingsResponse( $this->parseIndexedEmbeddings($data['data'] ?? [], count($inputs), $provider->name()), new Usage($data['usage']['prompt_tokens'] ?? 0), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } } diff --git a/src/ai/src/Gateway/OpenAiCompatible/OpenAiCompatibleGateway.php b/src/ai/src/Gateway/OpenAiCompatible/OpenAiCompatibleGateway.php index 1802f28f1a..d32a165103 100644 --- a/src/ai/src/Gateway/OpenAiCompatible/OpenAiCompatibleGateway.php +++ b/src/ai/src/Gateway/OpenAiCompatible/OpenAiCompatibleGateway.php @@ -87,11 +87,11 @@ public function generateEmbeddings( $this->validateTextResponse($data); - return new EmbeddingsResponse( + return (new EmbeddingsResponse( $this->parseEmbeddings($data, count($inputs)), new Usage($data['usage']['prompt_tokens'] ?? 0), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** @@ -136,7 +136,7 @@ public function generateTranscription( $data = $response->json(); - return new TranscriptionResponse( + return (new TranscriptionResponse( $data['text'] ?? '', collect($data['segments'] ?? [])->map(fn (array $segment): TranscriptionSegment => new TranscriptionSegment( $segment['text'] ?? '', @@ -150,7 +150,7 @@ public function generateTranscription( audioSeconds: Arr::get($data, 'usage.seconds') ?? Arr::get($data, 'duration'), ), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** diff --git a/src/ai/src/Gateway/OpenRouter/OpenRouterGateway.php b/src/ai/src/Gateway/OpenRouter/OpenRouterGateway.php index 59037ec89e..73ee6da82d 100644 --- a/src/ai/src/Gateway/OpenRouter/OpenRouterGateway.php +++ b/src/ai/src/Gateway/OpenRouter/OpenRouterGateway.php @@ -148,14 +148,14 @@ public function generateImage( $this->validateTextResponse($data); - return new ImageResponse( + return (new ImageResponse( collect($data['data'] ?? [])->map(fn (array $image): GeneratedImage => new GeneratedImage( $image['b64_json'] ?? '', $image['media_type'] ?? 'image/png', )), new ImageUsage($data['usage']['prompt_tokens'] ?? 0, $data['usage']['completion_tokens'] ?? 0), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** @@ -291,7 +291,7 @@ public function generateTranscription( $data = $response->json(); - return new TranscriptionResponse( + return (new TranscriptionResponse( $data['text'] ?? '', collect(), new TranscriptionUsage( @@ -300,7 +300,7 @@ public function generateTranscription( audioSeconds: Arr::get($data, 'usage.seconds'), ), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** @@ -350,11 +350,11 @@ public function generateEmbeddings( $this->validateTextResponse($data); - return new EmbeddingsResponse( + return (new EmbeddingsResponse( $this->parseIndexedEmbeddings($data['data'] ?? [], count($inputs), $provider->name()), new Usage($data['usage']['prompt_tokens'] ?? 0), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** @@ -390,13 +390,13 @@ public function rerank( score: $result['relevance_score'], ))->all(); - return new RerankingResponse( + return (new RerankingResponse( $results, new RerankingUsage( inputTokens: $data['usage']['total_tokens'] ?? 0, searchUnits: $data['usage']['search_units'] ?? null, ), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } } diff --git a/src/ai/src/Gateway/RunContext.php b/src/ai/src/Gateway/RunContext.php index 645ff734fe..e51a22e127 100644 --- a/src/ai/src/Gateway/RunContext.php +++ b/src/ai/src/Gateway/RunContext.php @@ -32,6 +32,10 @@ class RunContext /** @var array */ protected array $steps = []; + protected ?ClaimsPendingApprovals $approvalStore = null; + + protected ?string $conversationId = null; + protected ?ApprovalClaim $approvalClaim = null; protected bool $toolWasInvoked = false; @@ -48,8 +52,6 @@ public function __construct( public readonly string $model, protected readonly Dispatcher $events, protected readonly ?Closure $contextRunner = null, - protected readonly ?ClaimsPendingApprovals $approvalStore = null, - protected readonly ?string $conversationId = null, ) { } @@ -65,6 +67,17 @@ public function contextRunner(): ?Closure return $this->contextRunner; } + /** + * Claim and record the approvals this run resumes through the given conversation's store. + */ + public function withApprovalStore(ClaimsPendingApprovals $store, string $conversationId): static + { + $this->approvalStore = $store; + $this->conversationId = $conversationId; + + return $this; + } + /** * Claim the stored pause before any of its tools execute. * @@ -76,7 +89,7 @@ public function claimPendingApprovals(array $toolCallIds): void return; } - /** @var string $conversationId The provider supplies both the store and its conversation ID. */ + /** @var string $conversationId withApprovalStore() sets the store and its conversation ID together. */ $conversationId = $this->conversationId; $this->approvalClaim = $this->approvalStore->claimPendingApprovals($conversationId, $toolCallIds) diff --git a/src/ai/src/Gateway/TextGenerationLoop.php b/src/ai/src/Gateway/TextGenerationLoop.php index 52270d4ead..96bb4a79eb 100644 --- a/src/ai/src/Gateway/TextGenerationLoop.php +++ b/src/ai/src/Gateway/TextGenerationLoop.php @@ -27,6 +27,7 @@ use Hypervel\Ai\Exceptions\ApprovalMismatchException; use Hypervel\Ai\Exceptions\NoSuchToolException; use Hypervel\Ai\Exceptions\StreamErrorException; +use Hypervel\Ai\Exceptions\ToolExecutionException; use Hypervel\Ai\Gateway\Concerns\HandlesToolApprovals; use Hypervel\Ai\Gateway\Concerns\InvokesTools; use Hypervel\Ai\Messages\AssistantMessage; @@ -808,7 +809,8 @@ protected function preliminaryToolResults(Generator $events, ToolCall $toolCall, $reportedAt = 0; foreach ($events as $event) { - if ($event instanceof StreamStart) { + // Step starts and the sub-agent's tool results let combine() separate steps as the final result does... + if ($event instanceof StreamStart || $event instanceof ToolResultEvent) { $deltas[] = $event; continue; @@ -1012,15 +1014,37 @@ private function concurrentToolResults(array $resolved, int $max, Closure $execu */ protected function resolvedToolResult(ToolCall $toolCall, ?Tool $tool, bool $isFinalStep, array $tools = [], ?RunContext $context = null): ToolResult { - return $this->recordedToolResult($this->toolResult( - $toolCall, - match (true) { - ! $tool instanceof Tool && CoroutineContext::get(self::REPAIR_TOOL_CALLS_CONTEXT_KEY, false) => "Tool '{$toolCall->name}' does not exist. Available tools: {$this->availableToolNames($tools)}.", - $isFinalStep => 'The agent reached its maximum number of steps without running this tool call.', - default => $this->executeTool($tool, $toolCall->arguments, $toolCall->id, $context), - }, - failed: ! $tool instanceof Tool || $isFinalStep, - ), $context); + return $this->recordedToolResult(match (true) { + ! $tool instanceof Tool && CoroutineContext::get(self::REPAIR_TOOL_CALLS_CONTEXT_KEY, false) => $this->toolResult($toolCall, "Tool '{$toolCall->name}' does not exist. Available tools: {$this->availableToolNames($tools)}.", failed: true), + $isFinalStep => $this->toolResult($toolCall, 'The agent reached its maximum number of steps without running this tool call.', failed: true), + default => $this->executedToolResult($toolCall, $tool, $context), + }, $context); + } + + /** + * Execute the tool for the given call, returning a failure to the model instead of ending the run. + */ + protected function executedToolResult(ToolCall $toolCall, Tool $tool, ?RunContext $context = null): ToolResult + { + try { + return $this->toolResult($toolCall, $this->executeTool($tool, $toolCall->arguments, $toolCall->id, $context)); + } catch (ToolExecutionException $exception) { + report($exception->exception); + + return $this->toolResult($toolCall, $this->failedToolResultText($exception->exception), failed: true); + } + } + + /** + * Get the result the model receives for a tool that threw. + */ + protected function failedToolResultText(Throwable $exception): string + { + // Unlike upstream, the exception message is only included in debug mode. It can carry query bindings, + // server paths and other details that should not reach the provider, the conversation or the client... + return config()->boolean('app.debug') + ? 'The tool call failed: ' . $exception->getMessage() + : 'The tool call failed.'; } /** @@ -1177,15 +1201,15 @@ protected function resolveApprovalResults(array $approval, array $messages, arra $failed = false; + // Unlike upstream, only the tool's own failure is reported and returned to the model, as in executedToolResult(). + // A listener exception or cancellation ends the run, so a tool that ran is never presented to the model as failed... try { $result = $this->executeTool($tool, $arguments, $toolCall->id, $context); - } catch (Throwable $exception) { - if (Coroutine::causedByCancellation($exception)) { - throw $exception; - } + } catch (ToolExecutionException $exception) { + report($exception->exception); $failed = true; - $result = 'The tool call failed: ' . $exception->getMessage(); + $result = $this->failedToolResultText($exception->exception); } $toolResults[] = $result = new ToolResult( @@ -1203,6 +1227,36 @@ protected function resolveApprovalResults(array $approval, array $messages, arra return [$toolResults, ! $hasBareRejection]; } + /** + * Determine whether the history ends in a pause a real run could have left: every pending call is for a tool the agent still has, and at least one of them asks for approval. + * + * @param Message[] $messages + * @param array $tools + */ + public function pausedForApproval(array $messages, array $tools): bool + { + [$pendingToolCalls] = $this->pendingToolCalls($messages); + + $pendingTools = $this->toolsForPendingCalls($pendingToolCalls, $tools); + + return ! $pendingTools->containsStrict(null) + && $pendingToolCalls->contains(fn (ToolCall $toolCall): bool => $this->approvalForTool($pendingTools[$toolCall->id], $toolCall) instanceof Approval); + } + + /** + * Find the tool each pending call is for, keyed by the call's id. + * + * @param Collection $pendingToolCalls + * @param array $tools + * @return Collection + */ + protected function toolsForPendingCalls(Collection $pendingToolCalls, array $tools): Collection + { + return $pendingToolCalls->mapWithKeys(fn (ToolCall $toolCall): array => [ + $toolCall->id => $this->findTool($toolCall->name, $tools), + ]); + } + /** * Validate the approval's decisions against the pending tool calls, throwing on any mismatch. * @@ -1221,9 +1275,7 @@ public function validateApproval(array $approval, array $messages, array $tools) fn (int|string $id): bool => $id === '*', ); - $resolvedTools = $pendingToolCalls->mapWithKeys(fn (ToolCall $toolCall): array => [ - $toolCall->id => $this->findTool($toolCall->name, $tools), - ]); + $resolvedTools = $this->toolsForPendingCalls($pendingToolCalls, $tools); $approvals = $pendingToolCalls->mapWithKeys(fn (ToolCall $toolCall): array => [ $toolCall->id => $this->approvalForTool($resolvedTools[$toolCall->id], $toolCall), diff --git a/src/ai/src/Gateway/VoyageAi/Concerns/MapsEmbeddingInputs.php b/src/ai/src/Gateway/VoyageAi/Concerns/MapsEmbeddingInputs.php index 154c79f72d..a40e4bbd23 100644 --- a/src/ai/src/Gateway/VoyageAi/Concerns/MapsEmbeddingInputs.php +++ b/src/ai/src/Gateway/VoyageAi/Concerns/MapsEmbeddingInputs.php @@ -51,7 +51,7 @@ protected function generateMultimodalEmbeddings( $this->validateMultimodalEmbeddingInputSources($inputs); - $data = $this->withErrorHandling( + $response = $this->withErrorHandling( $provider->name(), fn () => $this->client($provider, $timeout)->post('/multimodalembeddings', array_merge($providerOptions, [ 'model' => $model, @@ -59,13 +59,15 @@ protected function generateMultimodalEmbeddings( 'content' => [$this->mapMultimodalEmbeddingInput($input)], ], $inputs), ], $model === 'voyage-multimodal-3' ? [] : ['output_dimension' => $dimensions])), - )->json(); + ); + + $data = $response->json(); - return new EmbeddingsResponse( + return (new EmbeddingsResponse( (new Collection($data['data'] ?? []))->pluck('embedding')->all(), new Usage($data['usage']['total_tokens'] ?? $data['total_tokens'] ?? 0), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** diff --git a/src/ai/src/Gateway/VoyageAi/VoyageAiGateway.php b/src/ai/src/Gateway/VoyageAi/VoyageAiGateway.php index b91f35dc8e..befcef3095 100644 --- a/src/ai/src/Gateway/VoyageAi/VoyageAiGateway.php +++ b/src/ai/src/Gateway/VoyageAi/VoyageAiGateway.php @@ -43,20 +43,22 @@ public function generateEmbeddings( return $this->generateMultimodalEmbeddings($provider, $model, $inputs, $dimensions, $timeout, $providerOptions); } - $data = $this->withErrorHandling( + $response = $this->withErrorHandling( $provider->name(), fn () => $this->client($provider, $timeout)->post('/embeddings', array_merge($providerOptions, [ 'model' => $model, 'input' => $inputs, 'output_dimension' => $dimensions, ])), - )->json(); + ); + + $data = $response->json(); - return new EmbeddingsResponse( + return (new EmbeddingsResponse( (new Collection($data['data']))->pluck('embedding')->all(), new Usage($data['usage']['total_tokens'] ?? 0), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** @@ -75,7 +77,7 @@ public function rerank( array $providerOptions = [], ): RerankingResponse { /** @var Provider&RerankingProvider $provider */ - $data = $this->withErrorHandling( + $response = $this->withErrorHandling( $provider->name(), fn () => $this->client($provider, $timeout)->post('/rerank', array_merge($providerOptions, Arr::whereNotNull([ 'model' => $model, @@ -83,9 +85,11 @@ public function rerank( 'documents' => $documents, 'top_k' => $limit, ]))), - )->json(); + ); + + $data = $response->json(); - return new RerankingResponse( + return (new RerankingResponse( collect($data['data'])->map(fn (array $result): RankedDocument => new RankedDocument( index: $result['index'], document: $documents[$result['index']], @@ -93,6 +97,6 @@ public function rerank( ))->all(), new RerankingUsage($data['usage']['total_tokens'] ?? 0), new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } } diff --git a/src/ai/src/Gateway/Xai/XaiImageGateway.php b/src/ai/src/Gateway/Xai/XaiImageGateway.php index bc3c7d414f..ed3a13d5bd 100644 --- a/src/ai/src/Gateway/Xai/XaiImageGateway.php +++ b/src/ai/src/Gateway/Xai/XaiImageGateway.php @@ -66,16 +66,16 @@ public function generateImage( ->post($attachments === [] ? 'images/generations' : 'images/edits', $body) ); - $response = $response->json(); + $data = $response->json(); - return new ImageResponse( - collect($response['data'] ?? [])->map(fn (array $image): GeneratedImage => new GeneratedImage( + return (new ImageResponse( + collect($data['data'] ?? [])->map(fn (array $image): GeneratedImage => new GeneratedImage( $image['b64_json'] ?? '', $image['mime_type'] ?? 'image/jpeg', )), new ImageUsage, new Meta($provider->name(), $model), - ); + ))->withRawResponse($response); } /** diff --git a/src/ai/src/Middleware/RememberConversation.php b/src/ai/src/Middleware/RememberConversation.php index e448c6da2e..0b1cb91e34 100644 --- a/src/ai/src/Middleware/RememberConversation.php +++ b/src/ai/src/Middleware/RememberConversation.php @@ -7,6 +7,7 @@ use Closure; use Generator; use Hypervel\Ai\Ai; +use Hypervel\Ai\AnonymousAgent; use Hypervel\Ai\Concerns\RemembersConversations as RemembersConversationsTrait; use Hypervel\Ai\Contracts\Agent; use Hypervel\Ai\Contracts\ConversationStore; @@ -216,7 +217,7 @@ protected function rememberTurn(AgentPrompt $prompt, AgentResponse $response, ?s if ($newConversation) { if (($exception === null || ! Coroutine::causedByCancellation($exception)) && ! $exception instanceof StreamAbandonedException) { try { - $title = $prompt->pendingConversationTitle()?->value() ?? $this->generateTitle($prompt->prompt); + $title = $prompt->pendingConversationTitle()?->value() ?? $this->generateTitle($prompt); } catch (ChildCancellationException) { // An independently canceled title must not discard the caller's completed turn. } catch (Throwable $caught) { @@ -360,7 +361,7 @@ protected function startConcurrentTitle(AgentPrompt $prompt): ?PendingConversati DB::releaseIdleConnections(); return new PendingConversationTitle( - fn (): string => $this->generateTitle($prompt->prompt), + fn (): string => $this->generateTitle($prompt), $prompt->contextRunner() ?? Ai::captureContext(), ); } @@ -368,19 +369,25 @@ protected function startConcurrentTitle(AgentPrompt $prompt): ?PendingConversati /** * Generate a title for the conversation. */ - protected function generateTitle(string $prompt): string + protected function generateTitle(AgentPrompt $prompt): string { if (! config()->boolean('ai.conversations.generate_title', true)) { - return Str::limit($prompt, 50, preserveWords: true); + return Str::limit($prompt->prompt, 50, preserveWords: true); } try { - $response = $this->provider->textGenerationLoop()->generate( + $response = $this->provider->prompt(new AgentPrompt( + new AnonymousAgent( + 'Generate a concise 3-5 word title for a conversation that starts with the following message. Use the same language as the message. Respond with only the title, no quotes or punctuation.', + [], + [], + ), + Str::limit($prompt->prompt, 500), + [], $this->provider, $this->provider->cheapestTextModel(), - 'Generate a concise 3-5 word title for a conversation that starts with the following message. Use the same language as the message. Respond with only the title, no quotes or punctuation.', - [new UserMessage(Str::limit($prompt, 500))], - ); + parentInvocationId: $prompt->invocationId, + )); return Str::limit($response->text, 100); } catch (Throwable $exception) { @@ -388,7 +395,7 @@ protected function generateTitle(string $prompt): string throw $exception; } - return Str::limit($prompt, 100, preserveWords: true); + return Str::limit($prompt->prompt, 100, preserveWords: true); } } } diff --git a/src/ai/src/PendingResponses/PendingEmbeddingsGeneration.php b/src/ai/src/PendingResponses/PendingEmbeddingsGeneration.php index fb1bee7e87..580d50d075 100644 --- a/src/ai/src/PendingResponses/PendingEmbeddingsGeneration.php +++ b/src/ai/src/PendingResponses/PendingEmbeddingsGeneration.php @@ -236,7 +236,8 @@ protected function generateWithIndividualCaching(EmbeddingProvider $provider, st ksort($embeddings); - return new EmbeddingsResponse(array_values($embeddings), $response->usage, $response->meta); + return (new EmbeddingsResponse(array_values($embeddings), $response->usage, $response->meta)) + ->withRawResponse($response->raw); } /** diff --git a/src/ai/src/Providers/Concerns/GeneratesText.php b/src/ai/src/Providers/Concerns/GeneratesText.php index 2ea0713471..9074b8f6d6 100644 --- a/src/ai/src/Providers/Concerns/GeneratesText.php +++ b/src/ai/src/Providers/Concerns/GeneratesText.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Ai\Ai; +use Hypervel\Ai\Approvals\Decision; use Hypervel\Ai\Contracts\Agent; use Hypervel\Ai\Contracts\ClaimsPendingApprovals; use Hypervel\Ai\Contracts\Conversational; @@ -80,6 +81,7 @@ public function prompt(AgentPrompt $prompt): AgentResponse $schema = $agent instanceof HasStructuredOutput ? $agent->schema(new JsonSchemaTypeFactory) : null; $instructions = (string) $agent->instructions(); $tools = $this->resolveTools($prompt); + $approval = $this->resumableApprovalFor($prompt, $messages, $tools); $options = TextGenerationOptions::forAgent($agent); if ($prompt->middleware !== []) { @@ -95,9 +97,9 @@ public function prompt(AgentPrompt $prompt): AgentResponse $schema, $options, $prompt->timeout, - $this->resumableApprovalFor($prompt), - $this->approvalResultRecorderFor($prompt, $resolvedApprovalResults), - $this->runContextFor($invocationId, $prompt), + $approval, + $this->approvalResultRecorderFor($prompt, $approval, $resolvedApprovalResults), + $this->prepareRunContext($invocationId, $prompt, $approval), ); if ($response->hasPendingApprovals()) { @@ -224,27 +226,38 @@ protected function resolveTool(mixed $tool): mixed } /** - * Build the context that identifies this run and reports its step and tool events. + * Build the run's context, letting a remembered conversation claim and record the approval decisions the run resumes. + * + * @param null|array $approval */ - protected function runContextFor(string $invocationId, AgentPrompt $prompt): RunContext + protected function prepareRunContext(string $invocationId, AgentPrompt $prompt, ?array $approval): RunContext { - $store = null; - $conversationId = null; + $conversationId = $approval !== null && RememberConversation::appliesTo($prompt->agent) + ? $prompt->conversationSelection()?->conversationId + : null; - if ($this->resumesAgainstRealGateway($prompt) && RememberConversation::appliesTo($prompt->agent)) { - $conversationId = $prompt->conversationSelection()?->conversationId; + if ($conversationId === null) { + return $this->runContextFor($invocationId, $prompt); + } - if ($conversationId !== null) { - $store = resolve(ConversationStore::class); + $store = resolve(ConversationStore::class); - if (! $store instanceof ClaimsPendingApprovals) { - throw new LogicException('Conversation store [' . $store::class . '] must implement [' . ClaimsPendingApprovals::class . '] to resume stored tool approvals.'); - } - } + // Checked before the context is published, so an unsupported store fails like any other pre-run error. + if (! $store instanceof ClaimsPendingApprovals) { + throw new LogicException('Conversation store [' . $store::class . '] must implement [' . ClaimsPendingApprovals::class . '] to resume stored tool approvals.'); } + // Unlike upstream, the resumed approvals are claimed through the store instead of a separate result recorder. + return $this->runContextFor($invocationId, $prompt)->withApprovalStore($store, $conversationId); + } + + /** + * Build the context that identifies this run and reports its step and tool events. + */ + protected function runContextFor(string $invocationId, AgentPrompt $prompt): RunContext + { return tap( - new RunContext($invocationId, $prompt->agent, $this, $prompt->model, $this->events, $prompt->contextRunner(), $store, $conversationId), + new RunContext($invocationId, $prompt->agent, $this, $prompt->model, $this->events, $prompt->contextRunner()), fn (RunContext $context) => $prompt->setRunContext($context), ); } diff --git a/src/ai/src/Providers/Concerns/ResumesToolApprovals.php b/src/ai/src/Providers/Concerns/ResumesToolApprovals.php index 9898aa4fbb..b2a9169146 100644 --- a/src/ai/src/Providers/Concerns/ResumesToolApprovals.php +++ b/src/ai/src/Providers/Concerns/ResumesToolApprovals.php @@ -7,21 +7,25 @@ use Closure; use Hypervel\Ai\Ai; use Hypervel\Ai\Approvals\Decision; +use Hypervel\Ai\Contracts\Tool; use Hypervel\Ai\Messages\AssistantMessage; use Hypervel\Ai\Messages\Message; use Hypervel\Ai\Prompts\AgentPrompt; +use Hypervel\Ai\Providers\Tools\ProviderTool; use Hypervel\Support\Collection; trait ResumesToolApprovals { /** - * Get the tool approval to resume with, unless the agent's gateway is faked. + * Get the tool approval to resume with, unless a faked agent has no paused approval to resume. * + * @param array $messages + * @param array $tools * @return null|array */ - protected function resumableApprovalFor(AgentPrompt $prompt): ?array + protected function resumableApprovalFor(AgentPrompt $prompt, array $messages, array $tools): ?array { - return $this->resumesAgainstRealGateway($prompt) ? $prompt->approvalDecisions->all() : null; + return $this->resumesApproval($prompt, $messages, $tools) ? $prompt->approvalDecisions->all() : null; } /** @@ -45,19 +49,33 @@ protected function withoutForeignReplayBlocks(array $messages): array } /** - * Determine whether the prompt is a resume that runs tools against the real (non-faked) gateway. + * Determine whether the prompt is a resume that runs the approved tools. + * + * @param array $messages + * @param array $tools */ - protected function resumesAgainstRealGateway(AgentPrompt $prompt): bool + protected function resumesApproval(AgentPrompt $prompt, array $messages, array $tools): bool { - return $prompt->hasApprovalDecisions() && ! Ai::hasFakeGatewayFor($prompt->agent::class); + if (! $prompt->hasApprovalDecisions()) { + return false; + } + + if (! Ai::hasFakeGatewayFor($prompt->agent::class)) { + return true; + } + + // A faked agent resumes like the real gateway once it actually paused for approval, but decisions sent to a fake with nothing paused still just take its next response... + return $this->textGenerationLoop()->pausedForApproval($messages, $tools); } /** * Get a callback that captures a resume's resolved approval results for its completion event. + * + * @param null|array $approval */ - protected function approvalResultRecorderFor(AgentPrompt $prompt, ?Collection &$resolvedApprovalResults): ?Closure + protected function approvalResultRecorderFor(AgentPrompt $prompt, ?array $approval, ?Collection &$resolvedApprovalResults): ?Closure { - if (! $this->resumesAgainstRealGateway($prompt)) { + if ($approval === null) { return null; } diff --git a/src/ai/src/Providers/Concerns/StreamsText.php b/src/ai/src/Providers/Concerns/StreamsText.php index c0d6f8c252..3c10955b35 100644 --- a/src/ai/src/Providers/Concerns/StreamsText.php +++ b/src/ai/src/Providers/Concerns/StreamsText.php @@ -68,15 +68,15 @@ public function stream(AgentPrompt $prompt): StreamableAgentResponse } $tools = $this->resolveTools($prompt); - $approval = $this->resumableApprovalFor($prompt); - $recordApprovalResults = $this->approvalResultRecorderFor($prompt, $resolvedApprovalResults); + $approval = $this->resumableApprovalFor($prompt, $messages, $tools); + $recordApprovalResults = $this->approvalResultRecorderFor($prompt, $approval, $resolvedApprovalResults); // Validate eagerly so a mismatch throws before the stream begins, then thread the result into stream() so it isn't re-validated there... $validatedApproval = $approval !== null ? $this->textGenerationLoop()->validateApproval($approval, $messages, $tools) : null; - $context = $this->runContextFor($invocationId, $prompt); + $context = $this->prepareRunContext($invocationId, $prompt, $approval); $streamable = new StreamableAgentResponse( $invocationId, diff --git a/src/ai/src/Responses/Data/ScoreAnswer.php b/src/ai/src/Responses/Data/ScoreAnswer.php index dd64a1b8f5..a2ea714407 100644 --- a/src/ai/src/Responses/Data/ScoreAnswer.php +++ b/src/ai/src/Responses/Data/ScoreAnswer.php @@ -11,7 +11,7 @@ class ScoreAnswer extends Answer * * @param float $score the probability-weighted level, which may fall between two levels * @param array $probabilities - * @param array|string> $legend + * @param array|string> $legend * @param null|float $confidence null when the provider cannot measure the distribution's certainty */ public function __construct( @@ -35,7 +35,7 @@ public function level(): int /** * Get the description of the most probable level. * - * @return null|array|string + * @return null|array|string */ public function label(): string|array|null { diff --git a/src/ai/src/Responses/EmbeddingsResponse.php b/src/ai/src/Responses/EmbeddingsResponse.php index 6676be035e..42a8ab4745 100644 --- a/src/ai/src/Responses/EmbeddingsResponse.php +++ b/src/ai/src/Responses/EmbeddingsResponse.php @@ -5,6 +5,7 @@ namespace Hypervel\Ai\Responses; use Countable; +use Hypervel\Ai\Responses\Concerns\HasRawResponse; use Hypervel\Ai\Responses\Data\Meta; use Hypervel\Ai\Responses\Data\Usage; use Hypervel\Contracts\Support\Arrayable; @@ -14,6 +15,8 @@ class EmbeddingsResponse implements Arrayable, Countable, IteratorAggregate, JsonSerializable { + use HasRawResponse; + /** * Create a new embeddings response instance. * diff --git a/src/ai/src/Responses/ImageResponse.php b/src/ai/src/Responses/ImageResponse.php index 9cbf197879..4e31acba39 100644 --- a/src/ai/src/Responses/ImageResponse.php +++ b/src/ai/src/Responses/ImageResponse.php @@ -5,6 +5,7 @@ namespace Hypervel\Ai\Responses; use Countable; +use Hypervel\Ai\Responses\Concerns\HasRawResponse; use Hypervel\Ai\Responses\Data\GeneratedImage; use Hypervel\Ai\Responses\Data\ImageUsage; use Hypervel\Ai\Responses\Data\Meta; @@ -16,6 +17,8 @@ class ImageResponse implements Stringable, Countable, Htmlable { + use HasRawResponse; + /** * Create an image response. * diff --git a/src/ai/src/Responses/RerankingResponse.php b/src/ai/src/Responses/RerankingResponse.php index 8345d00732..1fddf54603 100644 --- a/src/ai/src/Responses/RerankingResponse.php +++ b/src/ai/src/Responses/RerankingResponse.php @@ -5,6 +5,7 @@ namespace Hypervel\Ai\Responses; use Countable; +use Hypervel\Ai\Responses\Concerns\HasRawResponse; use Hypervel\Ai\Responses\Data\Meta; use Hypervel\Ai\Responses\Data\RankedDocument; use Hypervel\Ai\Responses\Data\RerankingUsage; @@ -16,6 +17,8 @@ class RerankingResponse implements Arrayable, Countable, IteratorAggregate, JsonSerializable { + use HasRawResponse; + /** * Create a new reranking response instance. * diff --git a/src/ai/src/Responses/StreamedAgentResponse.php b/src/ai/src/Responses/StreamedAgentResponse.php index dd529424d4..0f41c1bc53 100644 --- a/src/ai/src/Responses/StreamedAgentResponse.php +++ b/src/ai/src/Responses/StreamedAgentResponse.php @@ -34,6 +34,7 @@ public function __construct(string $invocationId, Collection $events, Meta $meta { $textParts = []; $stepText = ''; + $stepRanTools = false; $reasoningBlocks = []; $usage = new TextUsage; $toolCalls = []; @@ -46,12 +47,15 @@ public function __construct(string $invocationId, Collection $events, Meta $meta // Citation::combine() and StreamEnd::combineUsage() when syncing upstream changes. foreach ($events as $event) { if ($event instanceof StreamStart) { - // Content-block message IDs may change mid-sentence; only a new step separates text. - if (trim($stepText) !== '') { - $textParts[] = $stepText; + // Content-block message IDs may change mid-sentence; only a new step after tools ran separates text. + if ($stepRanTools) { + if (trim($stepText) !== '') { + $textParts[] = $stepText; + } + + $stepText = ''; + $stepRanTools = false; } - - $stepText = ''; } elseif ($event instanceof TextDelta) { $stepText .= $event->delta; } elseif ($event instanceof ReasoningDelta) { @@ -63,6 +67,8 @@ public function __construct(string $invocationId, Collection $events, Meta $meta } elseif ($event instanceof ToolCall) { $toolCalls[] = $event->toolCall; } elseif ($event instanceof ToolResult) { + $stepRanTools = true; + if (! $event->preliminary) { $toolResults[] = $event->toolResult; } diff --git a/src/ai/src/Responses/TranscriptionResponse.php b/src/ai/src/Responses/TranscriptionResponse.php index ef974f4ddb..093d5c21d9 100644 --- a/src/ai/src/Responses/TranscriptionResponse.php +++ b/src/ai/src/Responses/TranscriptionResponse.php @@ -4,6 +4,7 @@ namespace Hypervel\Ai\Responses; +use Hypervel\Ai\Responses\Concerns\HasRawResponse; use Hypervel\Ai\Responses\Data\Meta; use Hypervel\Ai\Responses\Data\TranscriptionSegment; use Hypervel\Ai\Responses\Data\TranscriptionUsage; @@ -12,6 +13,8 @@ class TranscriptionResponse implements Stringable { + use HasRawResponse; + public string $text; /** @var Collection */ diff --git a/src/ai/src/Storage/DatabaseConversationStore.php b/src/ai/src/Storage/DatabaseConversationStore.php index 15fe455b1a..2a5b279496 100644 --- a/src/ai/src/Storage/DatabaseConversationStore.php +++ b/src/ai/src/Storage/DatabaseConversationStore.php @@ -29,6 +29,8 @@ use Hypervel\Ai\Responses\Data\TextUsage; use Hypervel\Ai\Responses\Data\ToolCall; use Hypervel\Ai\Responses\Data\ToolResult; +use Hypervel\Ai\Responses\StreamedAgentResponse; +use Hypervel\Ai\Streaming\Events\TextDelta; use Hypervel\Ai\Support\ConversationPartition; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Pagination\CursorPaginator; @@ -334,10 +336,20 @@ protected function resumePausedRow(string $conversationId, object $paused, Agent $steps = $this->withoutReplayBlocks($steps); } + // Unlike upstream, a streamed resume appends its text to the paused content instead of rejoining every + // step, which would split a continuation the stream displayed as one paragraph, such as a pause_turn. + $content = $response instanceof StreamedAgentResponse + ? TextDelta::join([$paused->content, $response->text]) + : $response->text; + + if (blank($content)) { + $content = $paused->content; + } + $now = now(); $this->table($this->messagesTable())->where('id', $paused->id)->update([ - 'content' => blank($response->text) ? $paused->content : $response->text, + 'content' => $content, 'steps' => $steps->toJson(), 'usage' => json_encode(TextUsage::fromArray($this->decoded($paused->usage))->add($response->usage)), 'meta' => json_encode($this->mergedMeta($paused, $response, $exception)), diff --git a/src/ai/src/Streaming/Events/TextDelta.php b/src/ai/src/Streaming/Events/TextDelta.php index 7027224b72..02494b87ef 100644 --- a/src/ai/src/Streaming/Events/TextDelta.php +++ b/src/ai/src/Streaming/Events/TextDelta.php @@ -20,23 +20,27 @@ public function __construct( } /** - * Combine the text deltas in the given collection of events into a single string. - * - * Each step of a multi-step generation is a self-contained utterance - * (typically narration around a tool call), so steps are joined with a blank - * line instead of being run together mid-sentence. The boundary is the step's - * own `StreamStart` rather than a change of message ID, which Anthropic rotates - * per content block — web search splits one answer across several, mid-sentence. + * Combine the text deltas, starting a new paragraph only after a step that ran tools. */ public static function combine(Collection|array $events): string { - return Collection::wrap($events) - ->chunkWhile(fn (StreamEvent $event): bool => ! $event instanceof StreamStart) + return static::join(Collection::wrap($events) + ->chunkWhile(fn (StreamEvent $event, int|string $key, Collection $step): bool => ! $event instanceof StreamStart + || $step->doesntContain(fn (StreamEvent $previous): bool => $previous instanceof ToolResult)) ->map(fn (Collection $step): string => $step->whereInstanceOf(TextDelta::class) ->map(fn (TextDelta $event): string => $event->delta) - ->join('')) + ->join(''))); + } + + /** + * Join the text of each step, separating the steps with a blank line. + * + * @param array|Collection $texts + */ + public static function join(Collection|array $texts): string + { + return Collection::wrap($texts) ->filter(fn (string $text): bool => trim($text) !== '') - ->values() ->join("\n\n"); } diff --git a/src/ai/src/Tools/AgentTool.php b/src/ai/src/Tools/AgentTool.php index 4ac59e5ad7..c3a2babcd2 100644 --- a/src/ai/src/Tools/AgentTool.php +++ b/src/ai/src/Tools/AgentTool.php @@ -66,7 +66,7 @@ public function handle(Request $request): string } // Degraded to a result so the parent run survives; the failure surfaces as the sub-agent's own AgentFailed... - return 'Agent failed: ' . $throwable->getMessage(); + return $this->failedResult($throwable); } } @@ -92,10 +92,24 @@ public function stream(Request $request): Generator throw $throwable; } - return 'Agent failed: ' . $throwable->getMessage(); + return $this->failedResult($throwable); } } + /** + * Report the sub-agent's failure and get the result the parent agent receives. + */ + protected function failedResult(Throwable $throwable): string + { + // Unlike upstream, the failure is reported, since AgentFailed only reaches listeners and the parent run never sees the exception. + report($throwable); + + // Unlike upstream, the exception message is only included in debug mode, matching ordinary tool failures... + return config()->boolean('app.debug') + ? 'Agent failed: ' . $throwable->getMessage() + : 'Agent failed.'; + } + /** * Get the tool's schema definition. */ diff --git a/src/ai/src/Tools/Filesystem/CopyFile.php b/src/ai/src/Tools/Filesystem/CopyFile.php index 2927e9442e..9cbe5be8a1 100644 --- a/src/ai/src/Tools/Filesystem/CopyFile.php +++ b/src/ai/src/Tools/Filesystem/CopyFile.php @@ -42,7 +42,13 @@ public function handle(Request $request): string throw $throwable; } - return "Unable to copy [{$from}] to [{$to}]: {$throwable->getMessage()}"; + // Unlike upstream, the failure is reported, since the tool's result keeps it from the exception handler. + report($throwable); + + // Unlike upstream, the exception message is only included in debug mode, since it can carry server paths... + return config()->boolean('app.debug') + ? "Unable to copy [{$from}] to [{$to}]: {$throwable->getMessage()}" + : "Unable to copy [{$from}] to [{$to}]."; } return $copied diff --git a/src/ai/src/Tools/Filesystem/GetFileMetadata.php b/src/ai/src/Tools/Filesystem/GetFileMetadata.php index 52d76390ef..a2ff2c4416 100644 --- a/src/ai/src/Tools/Filesystem/GetFileMetadata.php +++ b/src/ai/src/Tools/Filesystem/GetFileMetadata.php @@ -37,7 +37,15 @@ public function handle(Request $request): string throw $exception; } - return "File [{$path}] does not exist."; + // Unlike upstream, a failed lookup of an existing file is reported instead of described as missing. + // Missing files and other failures throw the same exception, so only a failure checks existence... + if (! $this->fileExists($disk, $path)) { + return "File [{$path}] does not exist."; + } + + report($exception); + + return "Unable to get metadata for [{$path}]."; } return (string) json_encode([ diff --git a/src/ai/src/Tools/Filesystem/GetFileUrl.php b/src/ai/src/Tools/Filesystem/GetFileUrl.php index de88949cbe..ac19933bc2 100644 --- a/src/ai/src/Tools/Filesystem/GetFileUrl.php +++ b/src/ai/src/Tools/Filesystem/GetFileUrl.php @@ -48,7 +48,13 @@ public function handle(Request $request): string throw $throwable; } - return "Unable to generate a URL for [{$path}]: {$throwable->getMessage()}"; + // Unlike upstream, the failure is reported, since the tool's result keeps it from the exception handler. + report($throwable); + + // Unlike upstream, the exception message is only included in debug mode, since it can carry server paths... + return config()->boolean('app.debug') + ? "Unable to generate a URL for [{$path}]: {$throwable->getMessage()}" + : "Unable to generate a URL for [{$path}]."; } } diff --git a/src/ai/src/Tools/Filesystem/MoveFile.php b/src/ai/src/Tools/Filesystem/MoveFile.php index eadf8f3252..f18129ba97 100644 --- a/src/ai/src/Tools/Filesystem/MoveFile.php +++ b/src/ai/src/Tools/Filesystem/MoveFile.php @@ -42,7 +42,13 @@ public function handle(Request $request): string throw $throwable; } - return "Unable to move [{$from}] to [{$to}]: {$throwable->getMessage()}"; + // Unlike upstream, the failure is reported, since the tool's result keeps it from the exception handler. + report($throwable); + + // Unlike upstream, the exception message is only included in debug mode, since it can carry server paths... + return config()->boolean('app.debug') + ? "Unable to move [{$from}] to [{$to}]: {$throwable->getMessage()}" + : "Unable to move [{$from}] to [{$to}]."; } return $moved diff --git a/src/ai/src/Tools/Filesystem/ReadFile.php b/src/ai/src/Tools/Filesystem/ReadFile.php index 6943ad7fc8..be4f8d620a 100644 --- a/src/ai/src/Tools/Filesystem/ReadFile.php +++ b/src/ai/src/Tools/Filesystem/ReadFile.php @@ -42,7 +42,15 @@ public function handle(Request $request): string throw $exception; } - return "File [{$path}] does not exist."; + // Unlike upstream, a failed lookup of an existing file is reported instead of described as missing. + // Missing files and other failures throw the same exception, so only a failure checks existence... + if (! $this->fileExists($disk, $path)) { + return "File [{$path}] does not exist."; + } + + report($exception); + + return "Unable to read [{$path}]."; } if ($size > static::MAX_BYTES) { @@ -51,7 +59,12 @@ public function handle(Request $request): string $contents = $disk->get($path); - if ($contents === null || ! mb_check_encoding($contents, 'UTF-8')) { + // Disks that do not throw return null when the read fails. + if ($contents === null) { + return "Unable to read [{$path}]."; + } + + if (! mb_check_encoding($contents, 'UTF-8')) { return "File [{$path}] appears to be binary and cannot be read as text. Use GetFileUrl to access it."; } diff --git a/src/docs/ai-sdk.md b/src/docs/ai-sdk.md index 3122d43082..d3c97d4456 100644 --- a/src/docs/ai-sdk.md +++ b/src/docs/ai-sdk.md @@ -536,7 +536,7 @@ foreach ($response->steps as $step) { } ``` -[Classification](#classification) responses also expose the provider's raw HTTP response through their `raw` property. +[Image](#images), [transcription](#transcription), [embeddings](#embeddings), [reranking](#reranking), and [classification](#classification) responses also expose the provider's raw HTTP response through their `raw` property. Embeddings served entirely from the [cache](#caching-embeddings) have no raw response. > **Note:** The `raw` property is `null` when streaming a response, when using the Bedrock provider, and on faked responses unless one is provided explicitly via `withRawResponse`. @@ -677,6 +677,8 @@ The store uses your default database connection unless `ai.conversations.connect The SDK generates a title when it creates a conversation. To use the opening message as the title instead, set `conversations.generate_title` to `false` in your `config/ai.php` configuration file. +Title generation runs as its own agent run using the turn's provider and its cheapest model, so [agent events](#events) such as `AgentPrompted` and `AgentFailed` also report it. Its prompt's `parentInvocationId` identifies the turn that triggered it. If the title request fails, the SDK uses the start of the opening message without failing the turn. + If the opening turn is canceled or abandoned, such as when the client disconnects, the SDK saves the work already done without starting or waiting for a title. It keeps a title that has already finished or uses the start of the opening message, and the title isn't generated later. Enabling concurrent titles makes a generated title more likely in this case. A turn that fails with an error is titled as usual. By default, title generation runs after the agent responds. You may generate titles alongside the agent's response by enabling `conversations.concurrent_title`: @@ -1495,7 +1497,7 @@ class ResearchAgent implements Agent, HasTools } ``` -The limit applies to each group of tool calls within a generation step. Results retain the model's original call order, while sub-agent output can arrive as each sub-agent produces it. If a tool fails or the stream is abandoned, the remaining tool executions are canceled and awaited before the run ends. +The limit applies to each group of tool calls within a generation step. Results retain the model's original call order, while sub-agent output can arrive as each sub-agent produces it. If the run ends while tools are executing, for example because the stream is abandoned, the remaining tool executions are canceled and awaited first. Concurrent tools use separate coroutines and database connections; they do not share the caller's database transaction. Each tool must be safe to run alongside the others. Resuming approved tool calls remains sequential so each outcome is stored before the next approved tool executes. @@ -1520,6 +1522,8 @@ public function handle(Request $request): Stringable|string When validation fails, the validation messages are returned to the model as the tool's result, allowing it to correct the arguments and call the tool again. +Likewise, when a tool throws an exception, the run continues. The exception is reported to your application's exception handler, and the model receives "The tool call failed." as the tool's result, so it can explain the failure or try another approach. When your application's `debug` configuration option is enabled, the result also includes the exception message. Exception messages may contain sensitive details such as query bindings or server paths, so return a string from your tool when the model should know why an expected failure occurred. + #### Repairing Tool Calls @@ -1672,6 +1676,8 @@ return FileStorage::all('s3') ->reject(fn ($tool) => $tool instanceof DeleteFile); ``` +File storage tools return a description of the failed operation. As with other [tool failures](#validating-tool-arguments), raw exception messages are only included when your application's `debug` configuration option is enabled. + ### MCP Tools @@ -2005,6 +2011,8 @@ class RefundsAgent implements Agent, CanActAsTool, HasTools If a sub-agent does not implement `CanActAsTool`, Hypervel will use the agent's class basename as the tool name and a generic description that asks the parent agent to pass a clear, self-contained task description. Each sub-agent invocation runs in isolation and does not receive the parent agent's conversation history. +If a sub-agent fails, the parent agent's run continues. The exception is reported to your application's exception handler, and the parent agent receives "Agent failed." as the tool's result. Like other [tool failures](#validating-tool-arguments), the result also includes the exception message when your application's `debug` configuration option is enabled. + > [!WARNING] > Sub-agents do not currently support tools that require [human approval](#human-tool-approval). If a sub-agent's tool requires approval, the sub-agent pauses. However, the parent agent receives a normal tool result and continues its run without notifying your application of the pending approval. Register tools that require approval on the top-level agent. @@ -3730,6 +3738,8 @@ $response = (new FileAssistant)->prompt('Delete the invoice.'); $response->hasPendingApprovals(); // true ``` +When you resume a faked pause with [approval decisions](#human-tool-approval), approved tools run and rejected tools receive a denied result before the fake returns its next response, just as they would against a real provider. + In addition, you can fake a response that includes reasoning. The fake emits reasoning events, so the reasoning is reported on streamed runs as well: ```php diff --git a/src/docs/deployment.md b/src/docs/deployment.md index ee7ea09a36..1cb101a0be 100644 --- a/src/docs/deployment.md +++ b/src/docs/deployment.md @@ -89,6 +89,10 @@ server { proxy_pass http://127.0.0.1:8000; } + location ~ \.php$ { + return 404; + } + location ~ /\.(?!well-known).* { deny all; } diff --git a/src/docs/inertia.md b/src/docs/inertia.md index cd719c5d06..60cdb8e80c 100644 --- a/src/docs/inertia.md +++ b/src/docs/inertia.md @@ -63,6 +63,7 @@ Version labels refer to the Inertia client-side libraries. Features marked with - [Data Loading Attribute](#links-data-loading-attribute) - [Manual Visits](#manual-visits) - [Method](#manual-visits-method) + - [QUERY Method](#manual-visits-query-method) - [Wayfinder](#manual-visits-wayfinder) - [Data](#manual-visits-data) - [Custom Headers](#manual-visits-custom-headers) @@ -334,7 +335,7 @@ When Inertia makes an XHR visit, the server detects that it's an Inertia visit a **The end result is a silky smooth single-page experience.** -To learn more about the nitty-gritty, technical details of how Inertia works under the hood, check out the [protocol page](https://inertiajs.com/docs/v3/core-concepts/the-protocol). +The [protocol](https://inertiajs.com/docs/v3/core-concepts/the-protocol) page describes the requests and responses behind this exchange in detail. @@ -1533,9 +1534,9 @@ class UsersController extends Controller ### 303 Response Code -When redirecting after a `PUT`, `PATCH`, or `DELETE` request, you must use a `303` response code, otherwise the subsequent request will not be treated as a `GET` request. A `303` redirect is very similar to a `302` redirect; however, the follow-up request is explicitly changed to a `GET` request. +You must use a `303` response code when redirecting after a `PUT`, `PATCH`, `DELETE`, or `QUERY` request. Otherwise, the browser will not issue the subsequent request as a `GET` request. A `303` redirect is very similar to a `302` redirect; however, the follow-up request is explicitly changed to a `GET` request. -Hypervel's Inertia adapter automatically converts `302` redirects after `PUT`, `PATCH`, and `DELETE` requests to `303` redirects. +Hypervel's Inertia adapter automatically converts `302` redirects after `PUT`, `PATCH`, `DELETE`, and `QUERY` requests to `303` redirects. @@ -2054,13 +2055,13 @@ import { Link } from '@inertiajs/svelte' ``` > [!NOTE] -> Creating anchor (``) links that issue `POST`, `PUT`, `PATCH`, or `DELETE` requests is discouraged because it causes accessibility issues with "Open Link in New Tab / Window". The component automatically renders a `