$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; }