454 lines
23 KiB
PHP
454 lines
23 KiB
PHP
<?php
|
||
/**
|
||
* Agent runs (agent phase P4).
|
||
*
|
||
* The loop runs on the server, driven by short step requests from the browser. A run lives
|
||
* in content/ai-agent/runs/{id}.json together with its transcript; the browser never
|
||
* sends or sees the transcript, so it cannot forge tool results. One step does one thing:
|
||
* execute the pending tool calls, or make one model turn.
|
||
*
|
||
* status: running → needs_input | awaiting_review | failed | cancelled
|
||
*
|
||
* Limits apply per round (the start, and every user reply): turns, tool calls, runtime.
|
||
* The cost cap is cumulative for the whole run. Every model call is its own top-level
|
||
* ledger request "agent_{run}_{n}" so its usage is always recorded.
|
||
*/
|
||
|
||
require_once __DIR__ . '/prompt.php';
|
||
|
||
const RAIJU_AGENT_RUN_MAX_DEFERRALS = 4;
|
||
|
||
function raijuAgentRunPath(string $id): string {
|
||
return raijuAgentDir() . '/runs/' . $id . '.json';
|
||
}
|
||
|
||
function raijuAgentValidRunId($id): bool {
|
||
return is_string($id) && preg_match('/^run_[a-f0-9]{16}$/', $id) === 1;
|
||
}
|
||
|
||
function raijuAgentRunLoad(string $id): ?array {
|
||
return raijuAgentValidRunId($id) ? raijuAgentReadJsonFile(raijuAgentRunPath($id)) : null;
|
||
}
|
||
|
||
function raijuAgentRunSave(array &$run): void {
|
||
$run['updatedAt'] = date('c');
|
||
if (!raijuJsonAtomicWrite(raijuAgentRunPath($run['id']), $run)) {
|
||
throw new RuntimeException('Could not store the agent run.');
|
||
}
|
||
}
|
||
|
||
function raijuAgentCanSeeRun(array $run, array $actor): bool {
|
||
return !empty($actor['isAdmin']) || (!empty($actor['userKey']) && hash_equals((string)$run['owner'], (string)$actor['userKey']));
|
||
}
|
||
|
||
function raijuAgentRunEvent(array &$run, string $type, array $data = []): void {
|
||
$run['eventSeq'] = (int)($run['eventSeq'] ?? 0) + 1;
|
||
$run['events'][] = ['seq' => $run['eventSeq'], 'type' => $type, 'at' => date('c')] + $data;
|
||
$run['events'] = array_slice($run['events'], -200);
|
||
}
|
||
|
||
function raijuAgentRunFail(array &$run, string $code, string $message): void {
|
||
$run['status'] = 'failed';
|
||
$run['errorCode'] = $code;
|
||
$run['error'] = mb_substr($message, 0, 300, 'UTF-8');
|
||
$run['pending'] = [];
|
||
raijuAgentRunEvent($run, 'error', ['code' => $code, 'text' => $run['error']]);
|
||
raijuAgentAudit('agent-run-failed', false, ['run' => $run['id'], 'code' => $code]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Start, reply, cancel
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Start a run for a goal the user typed. The caller checks rights and rate limits. */
|
||
function raijuAgentRunStart(array $actor, string $goal, string $changesetId = ''): array {
|
||
$goal = trim($goal);
|
||
if (mb_strlen($goal, 'UTF-8') < 3) throw new InvalidArgumentException('Please describe what the agent should do.');
|
||
$goal = mb_substr($goal, 0, 2000, 'UTF-8');
|
||
if ($refusal = raijuAgentSafetyScreenRequest($goal)) {
|
||
raijuAgentAudit('agent-safety', false, ['stage' => 'request', 'category' => $refusal['category'], 'goalHash' => hash('sha256', $goal)]);
|
||
throw new InvalidArgumentException(raijuAgentSafetyRefusalText($refusal['category']));
|
||
}
|
||
$message = $goal;
|
||
if ($changesetId !== '') {
|
||
// Images the user dropped into the chat before sending were registered in a change set of their own.
|
||
$attached = raijuAgentChangesetLoad($changesetId);
|
||
if ($attached === null || (string)$attached['owner'] !== (string)$actor['userKey'] || $attached['status'] !== 'staging' || $attached['runId'] !== '') {
|
||
throw new InvalidArgumentException('The attached images are no longer available. Attach them again.');
|
||
}
|
||
$names = [];
|
||
foreach ($attached['media'] as $ref => $media) {
|
||
$names[] = $ref . ' (' . ($media['name'] !== '' ? $media['name'] . ', ' : '') . $media['width'] . '×' . $media['height'] . ')';
|
||
}
|
||
if ($names) $message .= "\n\n[Attached images: " . implode('; ', $names) . '. Use them with their mediaRef.]';
|
||
}
|
||
$run = [
|
||
'id' => 'run_' . bin2hex(random_bytes(8)),
|
||
'owner' => (string)$actor['userKey'],
|
||
'status' => 'running',
|
||
'createdAt' => date('c'),
|
||
'roundStartedAt' => time(),
|
||
'roundTurns' => 0, 'roundToolCalls' => 0,
|
||
'turns' => 0, 'toolCalls' => 0, 'costCents' => 0, 'unpricedTurns' => 0, 'nextTurn' => 0,
|
||
'failures' => [], 'transcript' => [['role' => 'user', 'content' => $message]], 'pending' => [],
|
||
'events' => [], 'eventSeq' => 0, 'links' => [], 'changesetId' => $changesetId, 'question' => '', 'summary' => '', 'openQuestions' => [], 'finalText' => '', 'error' => '',
|
||
];
|
||
if ($run['owner'] === '') throw new InvalidArgumentException('A run needs a signed-in user.');
|
||
raijuAgentRunEvent($run, 'start');
|
||
raijuAgentRunSave($run);
|
||
if ($changesetId !== '') {
|
||
raijuAgentChangesetMutate($changesetId, static function (array &$cs) use ($run) { $cs['runId'] = $run['id']; return true; });
|
||
raijuAgentChangesetAllowUrls($changesetId, raijuAgentExtractUrls($goal));
|
||
}
|
||
raijuAgentAudit('agent-run-start', true, ['run' => $run['id'], 'goalHash' => hash('sha256', $goal), 'goalLength' => mb_strlen($goal, 'UTF-8')]);
|
||
return $run;
|
||
}
|
||
|
||
/** The user answers a question or asks for changes after the review. */
|
||
function raijuAgentRunReply(string $runId, array $actor, string $message): array {
|
||
$message = mb_substr(trim($message), 0, 2000, 'UTF-8');
|
||
if ($message === '') return ['error' => 'Please write a message.'];
|
||
if ($refusal = raijuAgentSafetyScreenRequest($message)) {
|
||
raijuAgentAudit('agent-safety', false, ['stage' => 'request', 'category' => $refusal['category'], 'run' => $runId]);
|
||
return ['error' => raijuAgentSafetyRefusalText($refusal['category']), 'refused' => true];
|
||
}
|
||
return raijuAgentRunLocked($runId, $actor, static function (array &$run) use ($message, $actor): array {
|
||
if (!in_array($run['status'], ['needs_input', 'awaiting_review'], true)) {
|
||
return ['error' => 'This run is ' . $run['status'] . ' and cannot take a reply.'];
|
||
}
|
||
if (!empty($run['changesetId'])) {
|
||
raijuAgentChangesetReopen($run['changesetId'], $actor);
|
||
raijuAgentChangesetAllowUrls($run['changesetId'], raijuAgentExtractUrls($message));
|
||
}
|
||
$run['transcript'][] = ['role' => 'user', 'content' => $message];
|
||
$run['status'] = 'running';
|
||
$run['question'] = '';
|
||
$run['summary'] = '';
|
||
$run['openQuestions'] = [];
|
||
$run['finalText'] = '';
|
||
$run['links'] = [];
|
||
$run['roundStartedAt'] = time();
|
||
$run['roundTurns'] = 0;
|
||
$run['roundToolCalls'] = 0;
|
||
$run['failures'] = [];
|
||
raijuAgentRunEvent($run, 'reply');
|
||
return ['ok' => true];
|
||
});
|
||
}
|
||
|
||
function raijuAgentRunApplyCancel(array &$run, array $actor): array {
|
||
if (in_array($run['status'], ['failed', 'cancelled'], true)) return ['error' => 'This run is already ' . $run['status'] . '.'];
|
||
$run['status'] = 'cancelled';
|
||
$run['pending'] = [];
|
||
if (!empty($run['changesetId'])) raijuAgentChangesetDiscard($run['changesetId'], $actor);
|
||
raijuAgentRunEvent($run, 'cancelled');
|
||
raijuAgentAudit('agent-run-cancelled', true, ['run' => $run['id']]);
|
||
return ['ok' => true];
|
||
}
|
||
|
||
/** Cancel a run. While a step is in progress the request leaves a marker the step honours as soon as it can. */
|
||
function raijuAgentRunCancel(string $runId, array $actor): array {
|
||
$result = raijuAgentRunLocked($runId, $actor, static fn(array &$run): array => raijuAgentRunApplyCancel($run, $actor));
|
||
if (!empty($result['busy'])) {
|
||
@file_put_contents(raijuAgentRunPath($runId) . '.cancel', date('c'));
|
||
return ['ok' => true, 'status' => 'cancelling'];
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Load a run, check the owner, hold its lock without waiting, run $work, store it.
|
||
* A run that is being stepped elsewhere answers 'busy'.
|
||
*/
|
||
function raijuAgentRunLocked(string $runId, array $actor, callable $work, bool $wait = false): array {
|
||
$run = raijuAgentRunLoad($runId);
|
||
if ($run === null || !raijuAgentCanSeeRun($run, $actor)) return ['error' => 'Unknown run.', 'status' => 'missing'];
|
||
$lockFile = raijuAgentRunPath($runId) . '.step.lock';
|
||
$handle = @fopen($lockFile, 'c');
|
||
// A step never queues behind another step of the same run.
|
||
if ($handle === false || !flock($handle, LOCK_EX | LOCK_NB)) {
|
||
if (is_resource($handle)) fclose($handle);
|
||
return ['status' => 'busy', 'busy' => true, 'runId' => $runId];
|
||
}
|
||
try {
|
||
$run = raijuAgentRunLoad($runId);
|
||
if ($run === null) return ['error' => 'Unknown run.', 'status' => 'missing'];
|
||
$result = $work($run);
|
||
raijuAgentRunSave($run);
|
||
return $result + ['state' => raijuAgentRunPublicState($run)];
|
||
} finally {
|
||
flock($handle, LOCK_UN);
|
||
fclose($handle);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Public state
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** What the browser may see: status, a timeline without content, the question or summary. */
|
||
function raijuAgentRunPublicState(array $run, int $after = 0): array {
|
||
$limits = raijuAgentSettings()['limits'];
|
||
$state = [
|
||
'runId' => $run['id'],
|
||
'status' => $run['status'],
|
||
'events' => array_values(array_filter($run['events'], static fn($e) => $e['seq'] > $after)),
|
||
'cursor' => (int)($run['eventSeq'] ?? 0),
|
||
'changesetId' => $run['changesetId'] !== '' ? $run['changesetId'] : null,
|
||
'limitWarnings' => raijuAiUsageSummary()['warnings'],
|
||
'usage' => ['turns' => $run['turns'], 'toolCalls' => $run['toolCalls'], 'costCents' => $run['costCents'], 'unpricedTurns' => (int)($run['unpricedTurns'] ?? 0),
|
||
'limits' => ['maxTurns' => $limits['maxTurns'], 'maxToolCalls' => $limits['maxToolCalls'], 'maxCostCentsPerRun' => $limits['maxCostCentsPerRun']]],
|
||
];
|
||
if ($run['status'] === 'waiting_jobs') $state['jobs'] = array_values((array)($run['jobs'] ?? []));
|
||
if ($run['status'] === 'needs_input') $state['question'] = $run['question'] !== '' ? $run['question'] : $run['finalText'];
|
||
if ($run['status'] === 'awaiting_review') $state += ['summary' => $run['summary'], 'openQuestions' => $run['openQuestions']];
|
||
if ($run['status'] === 'failed') $state += ['error' => $run['error'], 'errorCode' => $run['errorCode'] ?? ''];
|
||
// Places in the dashboard the agent pointed to (cards that open in a new tab).
|
||
if (!empty($run['links']) && in_array($run['status'], ['needs_input', 'awaiting_review', 'failed'], true)) {
|
||
$state['links'] = array_map(static fn($link) => ['title' => $link['title'], 'path' => $link['path'], 'url' => $link['url'], 'note' => $link['note'], 'restricted' => !empty($link['restricted'])], array_values($run['links']));
|
||
}
|
||
return $state;
|
||
}
|
||
|
||
function raijuAgentRunStatus(string $runId, array $actor, int $after = 0): ?array {
|
||
$run = raijuAgentRunLoad($runId);
|
||
return $run !== null && raijuAgentCanSeeRun($run, $actor) ? raijuAgentRunPublicState($run, $after) : null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Messages for the model
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function raijuAgentChangesetSummaryLine(array $run): string {
|
||
$cs = $run['changesetId'] !== '' ? raijuAgentChangesetLoad($run['changesetId']) : null;
|
||
if ($cs === null || !$cs['ops']) return '';
|
||
$lines = [];
|
||
foreach (array_slice($cs['ops'], -15) as $op) {
|
||
$lines[] = $op['id'] . ' ' . $op['type'] . ' ' . ($op['target'] ?? '') . ' ' . ($op['path'] ?? ($op['listKey'] ?? ''));
|
||
}
|
||
return 'Change set so far (' . count($cs['ops']) . ' operations, status ' . $cs['status'] . "; last ones):\n" . implode("\n", $lines);
|
||
}
|
||
|
||
/**
|
||
* System prompt, a summary of the change set, and the transcript. When the input would exceed
|
||
* 60% of the configured limit, results of tools from older exchanges are replaced by their
|
||
* digest; the goal, the change set summary and the last three exchanges stay complete.
|
||
*/
|
||
function raijuAgentRunBuildMessages(array $run, array $aiSettings, array $tools): array {
|
||
$messages = [['role' => 'system', 'content' => raijuAgentSystemPrompt($run)]];
|
||
$summary = raijuAgentChangesetSummaryLine($run);
|
||
if ($summary !== '') $messages[] = ['role' => 'system', 'content' => $summary];
|
||
|
||
$transcript = $run['transcript'];
|
||
$exchangeStarts = [];
|
||
foreach ($transcript as $index => $message) {
|
||
if ($message['role'] === 'assistant') $exchangeStarts[] = $index;
|
||
}
|
||
$protectedFrom = count($exchangeStarts) > 3 ? $exchangeStarts[count($exchangeStarts) - 3] : 0;
|
||
$budget = (int)($aiSettings['limits']['maxInputTokens'] * 0.6);
|
||
$estimate = static fn(array $list) => raijuAiEstimateTokens((string)json_encode([$list, $tools], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||
|
||
$build = static function (array $transcript, int $shortenBefore): array {
|
||
$out = [];
|
||
foreach ($transcript as $index => $message) {
|
||
$entry = ['role' => $message['role'], 'content' => $message['content']];
|
||
if ($message['role'] === 'assistant' && !empty($message['toolCalls'])) {
|
||
$entry['toolCalls'] = $message['toolCalls'];
|
||
if (!empty($message['reasoningItems'])) $entry['reasoningItems'] = $message['reasoningItems'];
|
||
}
|
||
if ($message['role'] === 'tool') {
|
||
$entry['toolCallId'] = $message['toolCallId'];
|
||
$entry['isError'] = !empty($message['isError']);
|
||
if ($index < $shortenBefore && isset($message['digest'])) $entry['content'] = $message['digest'];
|
||
}
|
||
$out[] = $entry;
|
||
}
|
||
return $out;
|
||
};
|
||
$body = $build($transcript, 0);
|
||
if ($estimate(array_merge($messages, $body)) > $budget) {
|
||
// Shorten tool results of old exchanges, oldest first, until the input fits.
|
||
for ($shortenBefore = 1; $shortenBefore <= $protectedFrom; $shortenBefore++) {
|
||
if (($transcript[$shortenBefore - 1]['role'] ?? '') !== 'tool') continue;
|
||
$body = $build($transcript, $shortenBefore);
|
||
if ($estimate(array_merge($messages, $body)) <= $budget) break;
|
||
}
|
||
} else {
|
||
$body = $build($transcript, -1);
|
||
}
|
||
return array_merge($messages, $body);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The step
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function raijuAgentToolLabel(array $call): string {
|
||
$args = (array)($call['arguments'] ?? []);
|
||
$parts = [$call['name']];
|
||
foreach (['target', 'path', 'listKey', 'type', 'query', 'rid', 'url'] as $key) {
|
||
if (isset($args[$key]) && is_string($args[$key])) $parts[] = mb_substr($args[$key], 0, 60, 'UTF-8');
|
||
}
|
||
return implode(' ', $parts);
|
||
}
|
||
|
||
/** Execute the pending tool calls of a run, in order. */
|
||
function raijuAgentRunExecutePending(array &$run, array $actor, array $limits, float $deadline): void {
|
||
while ($run['pending'] && microtime(true) < $deadline) {
|
||
$call = $run['pending'][0];
|
||
if ($run['roundToolCalls'] >= $limits['maxToolCalls']) {
|
||
raijuAgentRunFail($run, 'maxToolCalls', 'The agent used up its tool calls for this round (' . $limits['maxToolCalls'] . ').');
|
||
return;
|
||
}
|
||
if ($call['error'] !== null) {
|
||
$json = (string)json_encode(['error' => $call['error'], 'hint' => 'Correct the arguments and call the tool again.'], JSON_UNESCAPED_UNICODE);
|
||
$outcome = ['content' => $json, 'isError' => true, 'digest' => $json];
|
||
} else {
|
||
try {
|
||
$outcome = raijuAgentExecuteTool($run, $call, $actor);
|
||
} catch (RaijuAgentToolWaiting $e) {
|
||
$run['status'] = 'waiting_jobs';
|
||
$run['jobs'] = [$e->jobId];
|
||
raijuAgentRunEvent($run, 'waiting', ['label' => 'image']);
|
||
return; // the next step looks at the job again
|
||
} catch (RaijuAgentToolDeferred $e) {
|
||
$run['pending'][0]['deferrals'] = (int)($call['deferrals'] ?? 0) + 1;
|
||
if ($run['pending'][0]['deferrals'] > RAIJU_AGENT_RUN_MAX_DEFERRALS) {
|
||
$json = (string)json_encode(['error' => 'This could not be completed on this server.', 'hint' => 'Continue without it.']);
|
||
$outcome = ['content' => $json, 'isError' => true, 'digest' => $json];
|
||
} else {
|
||
return; // continue in the next step, in a fresh process
|
||
}
|
||
}
|
||
}
|
||
array_shift($run['pending']);
|
||
if ($run['status'] === 'waiting_jobs') {
|
||
$run['status'] = 'running';
|
||
$run['jobs'] = [];
|
||
}
|
||
$run['toolCalls']++;
|
||
$run['roundToolCalls']++;
|
||
$run['transcript'][] = ['role' => 'tool', 'toolCallId' => $call['id'], 'content' => $outcome['content'], 'digest' => $outcome['digest'], 'isError' => $outcome['isError']];
|
||
raijuAgentRunEvent($run, 'tool', ['tool' => $call['name'], 'label' => raijuAgentToolLabel($call), 'ok' => !$outcome['isError']]);
|
||
$args = (array)($call['arguments'] ?? []);
|
||
raijuAgentAudit('agent-tool', !$outcome['isError'], ['run' => $run['id'], 'tool' => $call['name'], 'target' => isset($args['target']) ? (string)$args['target'] : null,
|
||
'path' => isset($args['path']) ? (string)$args['path'] : (isset($args['listKey']) ? (string)$args['listKey'] : null), 'argsHash' => hash('sha256', (string)json_encode($args))]);
|
||
|
||
if ($outcome['isError']) {
|
||
$run['failures'][$call['name']] = (int)($run['failures'][$call['name']] ?? 0) + 1;
|
||
if ($run['failures'][$call['name']] >= $limits['maxToolFailures']) {
|
||
raijuAgentRunFail($run, 'toolFailures', 'The agent could not get "' . $call['name'] . '" to work (' . $limits['maxToolFailures'] . ' failures in a row).');
|
||
return;
|
||
}
|
||
} else {
|
||
$run['failures'][$call['name']] = 0;
|
||
}
|
||
if (!empty(raijuAgentToolDefinitions()[$call['name']]['endsStep'])) return; // slow tools (web reads) get a step of their own
|
||
if (!empty($run['end'])) {
|
||
// The run stops here (a question or the review). Calls after it are answered, not executed,
|
||
// so the transcript stays consistent for the next round.
|
||
foreach ($run['pending'] as $skipped) {
|
||
$json = (string)json_encode(['error' => 'Skipped: the run is waiting for the user.']);
|
||
$run['transcript'][] = ['role' => 'tool', 'toolCallId' => $skipped['id'], 'content' => $json, 'digest' => $json, 'isError' => true];
|
||
}
|
||
$run['pending'] = [];
|
||
$run['status'] = $run['end'];
|
||
unset($run['end']);
|
||
raijuAgentRunEvent($run, $run['status'] === 'awaiting_review' ? 'review' : 'question');
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** One model turn. */
|
||
function raijuAgentRunModelTurn(array &$run, array $actor, array $limits): void {
|
||
if ($run['roundTurns'] >= $limits['maxTurns']) {
|
||
raijuAgentRunFail($run, 'maxTurns', 'The agent used up its turns for this round (' . $limits['maxTurns'] . ').');
|
||
return;
|
||
}
|
||
if ($run['costCents'] >= $limits['maxCostCentsPerRun']) {
|
||
raijuAgentRunFail($run, 'maxCost', 'The cost limit of this run was reached (' . $limits['maxCostCentsPerRun'] . ' cents, estimated).');
|
||
return;
|
||
}
|
||
$aiSettings = raijuAiLoadSettings(false);
|
||
$tools = raijuAgentToolsForUser();
|
||
$turn = ++$run['nextTurn'];
|
||
raijuAgentRunSave($run); // the ledger id is spent even if this request dies mid-call
|
||
try {
|
||
$result = raijuAiChatWithTools(raijuAgentRunBuildMessages($run, $aiSettings, $tools), $tools, ['_requestId' => 'agent_' . $run['id'] . '_' . $turn, 'temperature' => 0.2]);
|
||
} catch (Throwable $e) {
|
||
raijuAgentRunFail($run, 'model', $e instanceof RuntimeException ? $e->getMessage() : 'The AI request failed.');
|
||
return;
|
||
}
|
||
$run['turns']++;
|
||
$run['roundTurns']++;
|
||
// Every round counts with the model and price it was made with; a round without a known price adds nothing and is counted apart.
|
||
$run['costCents'] = raijuAiAddCents($run['costCents'] ?? 0, (float)$result['usage']['estimatedCostCents']);
|
||
if (empty($result['usage']['priced'])) $run['unpricedTurns'] = (int)($run['unpricedTurns'] ?? 0) + 1;
|
||
$run['transcript'][] = $result['message'];
|
||
if ($result['kind'] === 'tool_calls') {
|
||
$run['pending'] = array_map(static fn($call) => ['id' => $call['id'], 'name' => $call['name'], 'arguments' => $call['arguments'], 'error' => $call['error']], $result['toolCalls']);
|
||
raijuAgentRunEvent($run, 'turn', ['tools' => array_column($result['toolCalls'], 'name')]);
|
||
return;
|
||
}
|
||
// A plain answer: the agent has nothing to stage and speaks to the user.
|
||
$run['finalText'] = mb_substr($result['text'], 0, 4000, 'UTF-8');
|
||
$run['status'] = 'needs_input';
|
||
raijuAgentRunEvent($run, 'message');
|
||
}
|
||
|
||
/**
|
||
* One step of a run: execute pending tool calls, or make one model turn.
|
||
*
|
||
* @param array $options deadlineSeconds
|
||
* @return array public state, or {status: 'busy'} when another step of this run is in progress
|
||
*/
|
||
function raijuAgentRunStep(string $runId, array $actor, array $options = []): array {
|
||
return raijuAgentRunLocked($runId, $actor, static function (array &$run) use ($actor, $options): array {
|
||
$marker = raijuAgentRunPath($run['id']) . '.cancel';
|
||
if (is_file($marker)) {
|
||
@unlink($marker);
|
||
return raijuAgentRunApplyCancel($run, $actor);
|
||
}
|
||
if (!in_array($run['status'], ['running', 'waiting_jobs'], true)) return ['idle' => true];
|
||
$limits = raijuAgentSettings()['limits'];
|
||
$max = (int)ini_get('max_execution_time');
|
||
$seconds = (int)($options['deadlineSeconds'] ?? ($max > 0 ? min(50, $max - 15) : 50));
|
||
$deadline = microtime(true) + max(5, $seconds);
|
||
if (time() - (int)$run['roundStartedAt'] > $limits['maxRuntimeSeconds']) {
|
||
raijuAgentRunFail($run, 'timeout', 'The agent ran for too long (' . intdiv($limits['maxRuntimeSeconds'], 60) . ' minutes).');
|
||
return ['ok' => false];
|
||
}
|
||
if ($run['pending']) {
|
||
raijuAgentRunExecutePending($run, $actor, $limits, $deadline);
|
||
} else {
|
||
raijuAgentRunModelTurn($run, $actor, $limits);
|
||
}
|
||
if (is_file($marker)) { // cancelled while the model was thinking
|
||
@unlink($marker);
|
||
raijuAgentRunApplyCancel($run, $actor);
|
||
}
|
||
return ['ok' => true];
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cleanup
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Runs are kept for 24 hours after their last activity. */
|
||
function raijuAgentRunCleanup(?int $now = null): int {
|
||
$now ??= time();
|
||
$removed = 0;
|
||
foreach (glob(raijuAgentDir() . '/runs/*.json') ?: [] as $file) {
|
||
$run = raijuAgentReadJsonFile($file);
|
||
$age = $now - (int)strtotime((string)($run['updatedAt'] ?? 'now'));
|
||
if ($run !== null && $age > RAIJU_AGENT_STAGING_HOURS * 3600 && @unlink($file)) {
|
||
@unlink($file . '.step.lock');
|
||
@unlink($file . '.cancel');
|
||
$removed++;
|
||
}
|
||
}
|
||
return $removed;
|
||
}
|