Skip to content
This repository was archived by the owner on Mar 15, 2026. It is now read-only.

Commit 25cae5f

Browse files
feat: add per-run planning agent overrides
Allow synchronous planning runs to override both API key and provider per execution so operators can switch between Cursor and Claude Code without changing global configuration.
1 parent 3a8a0e4 commit 25cae5f

13 files changed

Lines changed: 209 additions & 48 deletions

File tree

src/LlmIntegration/Facade/LlmIntegrationFacade.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ public function runAgent(
2626
AgentCredentialsDto $credentials,
2727
string $githubToken,
2828
?string $containerName = null,
29+
?string $agentProviderOverride = null,
2930
): AgentRunResultDto {
30-
$agentApiKey = $this->resolveApiKey($role, $credentials);
31+
$agentApiKey = $this->resolveApiKey($role, $credentials, $agentProviderOverride);
3132

3233
$result = $this->cliAgentService->run(
3334
$role,
@@ -36,6 +37,7 @@ public function runAgent(
3637
$agentApiKey,
3738
$githubToken,
3839
$containerName,
40+
$agentProviderOverride,
3941
);
4042

4143
return new AgentRunResultDto(
@@ -46,9 +48,9 @@ public function runAgent(
4648
);
4749
}
4850

49-
private function resolveApiKey(AgentRole $role, AgentCredentialsDto $credentials): string
51+
private function resolveApiKey(AgentRole $role, AgentCredentialsDto $credentials, ?string $agentProviderOverride = null): string
5052
{
51-
$provider = $this->providerResolver->resolveProvider($role);
53+
$provider = $this->providerResolver->resolveProvider($role, $agentProviderOverride);
5254

5355
return match ($provider) {
5456
AgentProvider::CursorCli => $credentials->cursorApiKey ?? '',

src/LlmIntegration/Facade/LlmIntegrationFacadeInterface.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@ public function runAgent(
1717
AgentCredentialsDto $credentials,
1818
string $githubToken,
1919
?string $containerName = null,
20+
?string $agentProviderOverride = null,
2021
): AgentRunResultDto;
2122
}

src/LlmIntegration/Infrastructure/Service/CliAgentProviderResolver.php

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@ public function __construct(
2525
) {
2626
}
2727

28-
public function resolve(AgentRole $role): CliAgentDriverInterface
28+
public function resolve(AgentRole $role, ?string $agentProviderOverride = null): CliAgentDriverInterface
2929
{
30-
$provider = $this->resolveProvider($role);
30+
$provider = $this->resolveProvider($role, $agentProviderOverride);
3131

3232
$this->logger->info('[ProviderResolver] Resolved agent provider', [
33-
'role' => $role->value,
34-
'provider' => $provider->value,
33+
'role' => $role->value,
34+
'provider' => $provider->value,
35+
'providerOverride' => $agentProviderOverride,
3536
]);
3637

3738
return match ($provider) {
@@ -40,8 +41,13 @@ public function resolve(AgentRole $role): CliAgentDriverInterface
4041
};
4142
}
4243

43-
public function resolveProvider(AgentRole $role): AgentProvider
44+
public function resolveProvider(AgentRole $role, ?string $agentProviderOverride = null): AgentProvider
4445
{
46+
$rawOverride = $agentProviderOverride !== null ? trim($agentProviderOverride) : '';
47+
if ($rawOverride !== '') {
48+
return $this->parseProviderValue($rawOverride, $role, true);
49+
}
50+
4551
$raw = match ($role) {
4652
AgentRole::Planning => $this->planningProviderValue,
4753
AgentRole::Implementation => $this->implementationProviderValue,
@@ -51,11 +57,19 @@ public function resolveProvider(AgentRole $role): AgentProvider
5157
return AgentProvider::CursorCli;
5258
}
5359

60+
return $this->parseProviderValue($raw, $role, false);
61+
}
62+
63+
private function parseProviderValue(string $raw, AgentRole $role, bool $isOverride): AgentProvider
64+
{
5465
$provider = AgentProvider::tryFrom($raw);
5566
if ($provider === null) {
67+
$sourceLabel = $isOverride ? 'override' : 'configuration';
68+
5669
throw new InvalidArgumentException(sprintf(
57-
'Invalid agent provider "%s" for role "%s". Valid values: %s',
70+
'Invalid agent provider "%s" (%s) for role "%s". Valid values: %s',
5871
$raw,
72+
$sourceLabel,
5973
$role->value,
6074
implode(', ', array_map(
6175
static fn (AgentProvider $p): string => $p->value,

src/LlmIntegration/Infrastructure/Service/CliAgentService.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,21 @@ public function run(
2828
string $agentApiKey,
2929
string $githubToken,
3030
?string $containerName = null,
31+
?string $agentProviderOverride = null,
3132
): CliAgentRunResult {
3233
if ($this->simulateLlms) {
3334
$this->logger->info('SIMULATE_LLMS is enabled — delegating to simulated driver');
3435

3536
return $this->simulatedDriver->run($prompt, $workspacePath, $agentApiKey, $githubToken);
3637
}
3738

38-
$driver = $this->providerResolver->resolve($role);
39+
$driver = $this->providerResolver->resolve($role, $agentProviderOverride);
3940

4041
$this->logger->info('Using real CLI Agent', [
41-
'role' => $role->value,
42-
'containerized' => $containerName !== null,
43-
'containerName' => $containerName,
42+
'role' => $role->value,
43+
'containerized' => $containerName !== null,
44+
'containerName' => $containerName,
45+
'providerOverride' => $agentProviderOverride,
4446
]);
4547

4648
return $driver->run($prompt, $workspacePath, $agentApiKey, $githubToken, $containerName);

src/LlmIntegration/Infrastructure/Service/CliAgentServiceInterface.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ public function run(
1616
string $agentApiKey,
1717
string $githubToken,
1818
?string $containerName = null,
19+
?string $agentProviderOverride = null,
1920
): CliAgentRunResult;
2021
}

src/PlanningAgent/Facade/Message/PlanIssueMessage.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ public function __construct(
1313
public int $issueNumber,
1414
public bool $isResume,
1515
public string $runId,
16-
public ?string $cursorApiKeyOverride = null,
16+
public ?string $agentApiKeyOverride = null,
17+
public ?string $agentProviderOverride = null,
1718
) {
1819
}
1920
}

src/PlanningAgent/Infrastructure/Service/PlanningRunExecutor.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,11 @@ private function doExecute(PlanIssueMessage $message, ProductConfigDto $config):
125125
$config->githubToken,
126126
);
127127

128-
$prompt = $this->buildPrompt($issueDto, $comments, $message->isResume);
129-
$credentials = new AgentCredentialsDto(
130-
$message->cursorApiKeyOverride ?? $config->cursorAgentApiKey,
131-
$config->anthropicApiKey,
132-
);
128+
$prompt = $this->buildPrompt($issueDto, $comments, $message->isResume);
129+
$agentApiKeyOverride = $message->agentApiKeyOverride;
130+
$credentials = $agentApiKeyOverride !== null
131+
? new AgentCredentialsDto($agentApiKeyOverride, $agentApiKeyOverride)
132+
: new AgentCredentialsDto($config->cursorAgentApiKey, $config->anthropicApiKey);
133133

134134
$agentResult = $this->llmIntegrationFacade->runAgent(
135135
AgentRole::Planning,
@@ -138,6 +138,7 @@ private function doExecute(PlanIssueMessage $message, ProductConfigDto $config):
138138
$credentials,
139139
$config->githubToken,
140140
$workspaceInfo->containerName,
141+
$message->agentProviderOverride,
141142
);
142143

143144
$outcome = PlanningOutcome::fromAgentOutput($agentResult->success, $agentResult->resultText);

src/Workflow/Infrastructure/Command/RunPlanningAgentCommand.php

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use App\GithubIntegration\Facade\Enum\GithubLabel;
88
use App\GithubIntegration\Facade\GithubIntegrationFacadeInterface;
9+
use App\LlmIntegration\Infrastructure\Service\Enum\AgentProvider;
910
use App\Organization\Facade\OrganizationFacadeInterface;
1011
use App\PlanningAgent\Facade\Message\PlanIssueMessage;
1112
use App\PlanningAgent\Infrastructure\Service\PlanningRunExecutorInterface;
@@ -42,27 +43,39 @@ protected function configure(): void
4243
{
4344
$this->addArgument('issueUrl', InputArgument::REQUIRED, 'GitHub issue URL');
4445
$this->addOption('force', 'f', InputOption::VALUE_NONE, 'Clear planning blockers (run claims + planning-ongoing label) before executing.');
45-
$this->addOption('cursor-api-key', null, InputOption::VALUE_REQUIRED, 'Override Cursor API key for this run only.');
46+
$this->addOption('agent-api-key', null, InputOption::VALUE_REQUIRED, 'Override agent API key for this run only.');
47+
$this->addOption('agent', null, InputOption::VALUE_REQUIRED, 'Override agent provider for this run only: cursor-cli or claude-code-cli.');
4648
}
4749

4850
protected function execute(InputInterface $input, OutputInterface $output): int
4951
{
5052
$output->setVerbosity(OutputInterface::VERBOSITY_DEBUG);
5153

52-
$io = new SymfonyStyle($input, $output);
53-
$issueUrlArg = $input->getArgument('issueUrl');
54-
$issueUrl = is_string($issueUrlArg) ? trim($issueUrlArg) : '';
55-
$forceOption = $input->getOption('force');
56-
$isForceRun = $forceOption === true;
57-
$cursorApiKeyOption = $input->getOption('cursor-api-key');
58-
$cursorApiKeyOverride = is_string($cursorApiKeyOption) ? trim($cursorApiKeyOption) : '';
54+
$io = new SymfonyStyle($input, $output);
55+
$issueUrlArg = $input->getArgument('issueUrl');
56+
$issueUrl = is_string($issueUrlArg) ? trim($issueUrlArg) : '';
57+
$forceOption = $input->getOption('force');
58+
$isForceRun = $forceOption === true;
59+
$agentApiKeyOption = $input->getOption('agent-api-key');
60+
$agentApiKeyOverride = is_string($agentApiKeyOption) ? trim($agentApiKeyOption) : '';
61+
$agentOption = $input->getOption('agent');
62+
$agentProviderOverride = is_string($agentOption) ? trim($agentOption) : '';
5963

6064
if ($issueUrl === '') {
6165
$io->error('Argument "issueUrl" must be a non-empty GitHub issue URL.');
6266

6367
return self::INVALID;
6468
}
6569

70+
if ($agentProviderOverride !== '' && AgentProvider::tryFrom($agentProviderOverride) === null) {
71+
$io->error(sprintf(
72+
'Option "--agent" must be one of: %s.',
73+
implode(', ', array_map(static fn (AgentProvider $provider): string => $provider->value, AgentProvider::cases())),
74+
));
75+
76+
return self::INVALID;
77+
}
78+
6679
$issueReference = $this->githubIntegrationFacade->parseIssueUrl($issueUrl);
6780
if ($issueReference === null) {
6881
$io->error('Issue URL must look like https://github.com/<owner>/<repo>/issues/<number>.');
@@ -87,9 +100,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int
87100
$selectedConfig = $this->selectConfig($io, $matchingConfigs);
88101
$io->text(sprintf('Using ProductConfig "%s" (%s)', $selectedConfig->name, $selectedConfig->id));
89102

90-
$effectiveCursorApiKey = $cursorApiKeyOverride !== ''
91-
? $cursorApiKeyOverride
92-
: $selectedConfig->cursorAgentApiKey;
103+
$effectiveAgentApiKey = $agentApiKeyOverride;
104+
if ($effectiveAgentApiKey === '' && $agentProviderOverride !== '') {
105+
$effectiveAgentApiKey = $agentProviderOverride === AgentProvider::ClaudeCodeCli->value
106+
? ($selectedConfig->anthropicApiKey ?? '')
107+
: $selectedConfig->cursorAgentApiKey;
108+
}
93109

94110
$this->githubIntegrationFacade->ensureLabelsExist($selectedConfig->githubUrl, $selectedConfig->githubToken);
95111
$issue = $this->githubIntegrationFacade->getIssueByNumber(
@@ -151,13 +167,23 @@ protected function execute(InputInterface $input, OutputInterface $output): int
151167
}
152168

153169
$io->text(sprintf('Run claim created: %s', $runId));
154-
if ($effectiveCursorApiKey !== '') {
155-
$io->text(sprintf('Cursor API key (effective): %s', $this->maskApiKey($effectiveCursorApiKey)));
170+
if ($effectiveAgentApiKey !== '') {
171+
$io->text(sprintf('Agent API key (effective): %s', $this->maskApiKey($effectiveAgentApiKey)));
172+
}
173+
if ($agentProviderOverride !== '') {
174+
$io->text(sprintf('Agent provider (override): %s', $agentProviderOverride));
156175
}
157176
$io->text('Executing planning synchronously...');
158177

159178
$this->planningRunExecutor->execute(
160-
new PlanIssueMessage($selectedConfig->id, $issueNumber, false, $runId, $cursorApiKeyOverride !== '' ? $cursorApiKeyOverride : null),
179+
new PlanIssueMessage(
180+
$selectedConfig->id,
181+
$issueNumber,
182+
false,
183+
$runId,
184+
$agentApiKeyOverride !== '' ? $agentApiKeyOverride : null,
185+
$agentProviderOverride !== '' ? $agentProviderOverride : null,
186+
),
161187
);
162188

163189
$finalIssue = $this->githubIntegrationFacade->getIssueByNumber(

tests/Unit/ImplementationAgent/ImplementIssueHandlerTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ public function runAgent(
363363
AgentCredentialsDto $credentials,
364364
string $githubToken,
365365
?string $containerName = null,
366+
?string $agentProviderOverride = null,
366367
): AgentRunResultDto {
367368
return $this->result;
368369
}

tests/Unit/LlmIntegration/CursorCliAgentServiceTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public function __construct(private readonly CliAgentDriverInterface $driver)
1717
{
1818
}
1919

20-
public function resolve(AgentRole $role): CliAgentDriverInterface
20+
public function resolve(AgentRole $role, ?string $agentProviderOverride = null): CliAgentDriverInterface
2121
{
2222
return $this->driver;
2323
}

0 commit comments

Comments
 (0)