Skip to content

Commit d181185

Browse files
NeunerleiCopilot
andauthored
Refactor tool calling logic for improved clarity (#283)
* refactor: cleanup of the tool calling logic * fix: add "hasItems" to ToolCallCollection #283 (comment) * fix: harden OpenAiToolCallingTrait #283 (comment) * fix: security breaking-point for infinite tool calling loops Theoretically, this should not be an issue if the upstream clients handle the flags correctly, but it is a nice fallback gate if something is implemented incorrectly. #283 (comment) and #283 (comment) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: don't use ToolCallAiResponse as writable class #283 (comment) * fix: ensure LoggingClient does not leak private information Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 648bd6b commit d181185

31 files changed

Lines changed: 1165 additions & 450 deletions

_changelog/next.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@
88

99
[//]: # (- Improvements and enhancements that improve the user experience.)
1010

11+
- AI Model errors will now be logged to the log file in addition to being printed to the screen, making it easier to debug issues in production.
12+
1113
### Bugfix
1214

1315
[//]: # (- List of bugs that have been fixed in this version.)
1416

17+
- Refactoring and code cleanup of the tool calling logic, improving readability and maintainability.
18+
- `bin/env dev` now no longer dies after 300 seconds, allowing for longer-running development sessions without interruption.
19+
1520
### Deprecation
1621

1722
[//]: # (- List of features or functionalities that have been deprecated in this version.)

app/Services/AI/AiFactory.php

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
use App\Services\AI\Interfaces\ClientInterface;
1313
use App\Services\AI\Interfaces\ModelProviderInterface;
1414
use App\Services\AI\Providers\GenericModelProvider;
15+
use App\Services\AI\Tools\ToolRegistry;
16+
use App\Services\AI\Utils\LoggingClient;
1517
use App\Services\AI\Utils\ModelAwareClient;
18+
use App\Services\AI\Utils\ToolCallingClient;
1619
use App\Services\AI\Value\AiModel;
1720
use App\Services\AI\Value\AiModelContext;
1821
use App\Services\AI\Value\AvailableAiModels;
@@ -21,6 +24,7 @@
2124
use Illuminate\Config\Repository;
2225
use Illuminate\Container\Attributes\Singleton;
2326
use Psr\Container\ContainerInterface;
27+
use Psr\Log\LoggerInterface;
2428

2529
#[Singleton]
2630
class AiFactory
@@ -29,7 +33,8 @@ class AiFactory
2933

3034
public function __construct(
3135
private readonly ContainerInterface $container,
32-
private readonly Repository $config
36+
private readonly Repository $config,
37+
private readonly ToolRegistry $toolRegistry
3338
)
3439
{
3540
}
@@ -112,9 +117,15 @@ function (AiModel $model) use ($provider) {
112117
return $this->rememberInstance(
113118
'client_for_' . $provider->getConfig()->getId() . '_model_' . $model->getId(),
114119
function () use ($provider, $model) {
115-
return new ModelAwareClient(
116-
$this->getClientForProvider($provider),
117-
$model
120+
return new LoggingClient(
121+
new ToolCallingClient(
122+
new ModelAwareClient(
123+
$this->getClientForProvider($provider),
124+
$model
125+
),
126+
$this->toolRegistry
127+
),
128+
$this->container->get(LoggerInterface::class)
118129
);
119130
}
120131
);

app/Services/AI/AiService.php

Lines changed: 5 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,21 @@
55
namespace App\Services\AI;
66

77

8-
use App\Models\Ai\Tools\AiTool;
98
use App\Services\AI\Exception\ModelIdNotAvailableException;
109
use App\Services\AI\Exception\ModelNotInPayloadException;
1110
use App\Services\AI\Exception\NoModelSetInRequestException;
12-
use App\Services\AI\Tools\ToolExecutionService;
1311
use App\Services\AI\Value\AiModel;
1412
use App\Services\AI\Value\AiRequest;
1513
use App\Services\AI\Value\AiResponse;
1614
use App\Services\AI\Value\AvailableAiModels;
1715
use App\Services\AI\Value\ModelUsageType;
1816
use Illuminate\Container\Attributes\Singleton;
19-
use Illuminate\Support\Facades\Log;
2017

2118
#[Singleton]
2219
readonly class AiService
2320
{
2421
public function __construct(
25-
private AiFactory $factory,
26-
private ToolExecutionService $toolExecutionService
22+
private AiFactory $factory
2723
)
2824
{
2925
}
@@ -77,48 +73,12 @@ public function getModelOrFail(string $modelId, ?bool $external = null): AiModel
7773
* If the response contains tool calls, they will be automatically executed and a follow-up request will be sent.
7874
*
7975
* @param array|AiRequest $request Either an AiRequest object or an array representing the request payload.
80-
* @param int $maxToolRounds Maximum number of tool execution rounds to prevent infinite loops
8176
* @return AiResponse
8277
*/
83-
public function sendRequest(array|AiRequest $request, int $maxToolRounds = 2): AiResponse
78+
public function sendRequest(array|AiRequest $request): AiResponse
8479
{
8580
[$request, $model] = $this->resolveRequestAndModel($request);
86-
87-
$round = 0;
88-
$currentRequest = $request;
89-
while (true) {
90-
$response = $model->getClient()->sendRequest($currentRequest);
91-
// \Log::info($response->content);
92-
if (!$this->toolExecutionService->requiresToolExecution($response)) {
93-
return $response;
94-
}
95-
96-
Log::info('AiTool execution required', [
97-
'round' => $round + 1,
98-
'tool_count' => count($response->toolCalls),
99-
]);
100-
101-
$round++;
102-
103-
// If we've reached max rounds, send final request without tools
104-
if ($round >= $maxToolRounds) {
105-
Log::warning('Max tool execution rounds reached', ['max_rounds' => $maxToolRounds]);
106-
107-
$currentRequest = $this->toolExecutionService->buildFollowUpRequest(
108-
$currentRequest,
109-
$response,
110-
disableTools: true
111-
);
112-
113-
return $model->getClient()->sendRequest($currentRequest);
114-
}
115-
116-
// Build follow-up request with tool results
117-
$currentRequest = $this->toolExecutionService->buildFollowUpRequest(
118-
$currentRequest,
119-
$response
120-
);
121-
}
81+
return $model->getClient()->sendRequest($request);
12282
}
12383

12484
/**
@@ -129,108 +89,12 @@ public function sendRequest(array|AiRequest $request, int $maxToolRounds = 2): A
12989
* @param array|AiRequest $request Either an AiRequest object or an array representing the request payload.
13090
* @param callable(AiResponse $response): void $onData A callback function that will be called with each chunk of data received.
13191
* The function should accept a single parameter of type AiResponse.
132-
* @param int $maxToolRounds Maximum number of tool execution rounds to prevent infinite loops
13392
* @return void
13493
*/
135-
public function sendStreamRequest(array|AiRequest $request, callable $onData, int $maxToolRounds = 5): void
94+
public function sendStreamRequest(array|AiRequest $request, callable $onData): void
13695
{
13796
[$request, $model] = $this->resolveRequestAndModel($request);
138-
139-
$round = 0;
140-
$currentRequest = $request;
141-
$lastCompleteResponse = null;
142-
143-
while (true) {
144-
// Wrap onData to capture the final complete response and mask tool call completion
145-
$wrappedOnData = function(AiResponse $response) use ($onData, &$lastCompleteResponse, $round) {
146-
// If this is a tool call completion, don't tell the frontend it's done yet
147-
// We'll continue with follow-up requests
148-
if ($response->isDone && $response->finishReason === 'tool_calls') {
149-
// Create a modified response with isDone=false for the frontend
150-
$frontendResponse = new AiResponse(
151-
content: $response->content,
152-
usage: $response->usage,
153-
isDone: false, // Mask the completion
154-
error: $response->error,
155-
toolCalls: $response->toolCalls,
156-
finishReason: $response->finishReason,
157-
type: $response->type,
158-
status: $response->status
159-
);
160-
$onData($frontendResponse);
161-
$lastCompleteResponse = $response; // Keep the real response internally
162-
} else {
163-
// Normal response or final completion - send as is
164-
$onData($response);
165-
if ($response->isDone) {
166-
$lastCompleteResponse = $response;
167-
}
168-
}
169-
};
170-
171-
// Send the streaming request
172-
$model->getClient()->sendStreamRequest($currentRequest, $wrappedOnData);
173-
174-
// Check if tool execution is needed
175-
if (!$lastCompleteResponse || !$this->toolExecutionService->requiresToolExecution($lastCompleteResponse)) {
176-
// No tools needed or response complete, we're done
177-
return;
178-
}
179-
$round++;
180-
181-
// Check if we've reached max rounds
182-
if ($round >= $maxToolRounds) {
183-
Log::warning('Max tool execution rounds reached in stream', ['max_rounds' => $maxToolRounds]);
184-
185-
// Send status about max rounds
186-
$onData(
187-
new AiResponse(
188-
content: ['text' => ''],
189-
isDone: false,
190-
type: 'status',
191-
status: [
192-
'key' => 'max_execution',
193-
'value' => 'Maximum tool execution rounds reached. Generating final response...'
194-
]
195-
));
196-
197-
// Build final request with tools disabled
198-
$currentRequest = $this->toolExecutionService->buildFollowUpRequest(
199-
$currentRequest,
200-
$lastCompleteResponse,
201-
disableTools: true
202-
);
203-
// Send the final request directly and return
204-
$model->getClient()->sendStreamRequest($currentRequest, $onData);
205-
return;
206-
}
207-
208-
// Send status message about tool execution
209-
$toolNames = array_map(fn($tc) => $tc->name, $lastCompleteResponse->toolCalls);
210-
$capabilities = [];
211-
foreach ($toolNames as $toolName) {
212-
$tool = AiTool::where('name', $toolName)->firstOrFail();
213-
$capabilities[] = $tool->capability;
214-
}
215-
// \Log::info($capabilities);
216-
$onData(new AiResponse(
217-
content: ['text' => ''],
218-
isDone: false,
219-
type: 'status',
220-
status: [
221-
'key' => 'tool_call',
222-
'value' => $capabilities
223-
]
224-
));
225-
// Build follow-up request with tool results
226-
$currentRequest = $this->toolExecutionService->buildFollowUpRequest(
227-
$currentRequest,
228-
$lastCompleteResponse
229-
);
230-
231-
// Note: Don't reset $lastCompleteResponse to null here
232-
// It will be overwritten in the next iteration when isDone=true
233-
}
97+
$model->getClient()->sendStreamRequest($request, $onData);
23498
}
23599

236100
/**

app/Services/AI/Interfaces/ClientInterface.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,25 @@
66

77

88
use App\Services\AI\AiFactory;
9+
use App\Services\AI\Utils\ClientStack;
910
use App\Services\AI\Value\AiModel;
1011
use App\Services\AI\Value\AiRequest;
1112
use App\Services\AI\Value\AiResponse;
1213
use App\Services\AI\Value\ModelOnlineStatus;
1314

1415
interface ClientInterface
1516
{
17+
/**
18+
* Builds the client stack by collecting all clients in the chain.
19+
* Each client can choose to add itself and/or child clients to the stack.
20+
* The final result describes the chain from the top-level client down to the concrete model client,
21+
* and is used for dedicated instance lookup in a nested call context.
22+
* @param ClientStack $stack
23+
* @return ClientStack
24+
* @internal
25+
*/
26+
public function buildStack(ClientStack $stack): ClientStack;
27+
1628
/**
1729
* Injects the model provider to be used by the client.
1830
* This method is intended for internal use only and will be called by {@see AiFactory::getClientForProvider()}
@@ -21,15 +33,15 @@ interface ClientInterface
2133
* @internal
2234
*/
2335
public function setProvider(ModelProviderInterface $provider): void;
24-
36+
2537
/**
2638
* Sends a non-streaming request to the AI service.
2739
* The execution is synchronous and will wait for the full response.
2840
* @param AiRequest $request
2941
* @return AiResponse
3042
*/
3143
public function sendRequest(AiRequest $request): AiResponse;
32-
44+
3345
/**
3446
* Sends a streaming request to the AI service.
3547
* The execution is asynchronous and will invoke the $onData callback for each chunk of data received.
@@ -39,7 +51,7 @@ public function sendRequest(AiRequest $request): AiResponse;
3951
* @return void
4052
*/
4153
public function sendStreamRequest(AiRequest $request, callable $onData): void;
42-
54+
4355
/**
4456
* Sends a status check request for the given model.
4557
*

0 commit comments

Comments
 (0)