353 lines
13 KiB
PHP
353 lines
13 KiB
PHP
<?php
|
|
// API: /api/wevia-agent-exec.php v3
|
|
// Wave 313 - WEVIA Master Agent + CONSENSUS mode
|
|
// Doctrine: Avant exec, WEVIA demande avis N IA souveraines, vote majoritaire
|
|
|
|
header('Content-Type: text/event-stream');
|
|
header('Cache-Control: no-cache');
|
|
header('X-Accel-Buffering: no');
|
|
|
|
ob_implicit_flush(true);
|
|
@ob_end_flush();
|
|
|
|
function sse($event, $data) {
|
|
echo "event: $event\n";
|
|
echo "data: " . json_encode($data) . "\n\n";
|
|
@ob_flush(); @flush();
|
|
}
|
|
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
if (!$input || empty($input['task'])) {
|
|
sse('error', ['error' => 'Missing task']);
|
|
exit;
|
|
}
|
|
|
|
$task = $input['task'];
|
|
$max_steps = (int)($input['max_steps'] ?? 5);
|
|
$dry_run = !empty($input['dry_run']);
|
|
$consensus = !empty($input['consensus']);
|
|
$consensus_models = $input['consensus_models'] ?? ['cerebras-think', 'groq', 'gemini', 'mistral', 'cloudflare-ai'];
|
|
|
|
sse('start', ['task' => $task, 'max_steps' => $max_steps, 'dry_run' => $dry_run, 'consensus' => $consensus]);
|
|
|
|
// === SAFETY GUARDRAILS ===
|
|
function isSafeCmd($cmd) {
|
|
$blocklist = [
|
|
'/rm\s+-rf?\s+\/(?!tmp|var\/log|var\/www\/html\/proofs)/i',
|
|
'/dd\s+if=.*of=\/dev\/(sda|nvme|hda)/i',
|
|
'/mkfs\./i', '/:(){.*};:/i', '/curl.*\|\s*bash/i', '/wget.*\|\s*sh/i',
|
|
'/chattr\s+-i\s+\/etc/i', '/userdel\s+root/i', '/passwd\s+root/i',
|
|
'/shutdown|reboot|halt|poweroff/i', '/iptables\s+-F/i',
|
|
'/systemctl\s+(stop|disable)\s+(nginx|php|cron|sshd)/i'
|
|
];
|
|
foreach ($blocklist as $pattern) if (preg_match($pattern, $cmd)) return false;
|
|
return true;
|
|
}
|
|
|
|
function llmCall($system, $user, $model = 'cerebras-think', $max_tokens = 1500, $timeout = 25) {
|
|
$payload = [
|
|
'model' => $model,
|
|
'messages' => [
|
|
['role' => 'system', 'content' => $system],
|
|
['role' => 'user', 'content' => $user]
|
|
],
|
|
'max_tokens' => $max_tokens,
|
|
'temperature' => 0.2
|
|
];
|
|
$ch = curl_init('http://127.0.0.1:4000/v1/chat/completions');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
|
CURLOPT_TIMEOUT => $timeout
|
|
]);
|
|
$resp = curl_exec($ch);
|
|
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
$data = @json_decode($resp, true);
|
|
if ($http !== 200) return ['error' => "LLM HTTP $http", 'raw' => substr($resp, 0, 200)];
|
|
return [
|
|
'content' => $data['choices'][0]['message']['content'] ?? '',
|
|
'model' => $data['model'] ?? null,
|
|
'provider' => $data['provider'] ?? null
|
|
];
|
|
}
|
|
|
|
function execCmd($cmd, $timeout = 25) {
|
|
if (!isSafeCmd($cmd)) {
|
|
return ['ok' => false, 'error' => 'BLOCKED by safety guardrails', 'cmd' => $cmd];
|
|
}
|
|
$start = microtime(true);
|
|
$output = shell_exec("timeout $timeout bash -c " . escapeshellarg($cmd) . " 2>&1");
|
|
$duration_ms = round((microtime(true) - $start) * 1000);
|
|
return [
|
|
'ok' => true,
|
|
'cmd' => $cmd,
|
|
'output' => substr((string)$output, 0, 4000),
|
|
'duration_ms' => $duration_ms
|
|
];
|
|
}
|
|
|
|
function parsePlanJson($text) {
|
|
$text = preg_replace('/^```(?:json)?\s*/m', '', $text);
|
|
$text = preg_replace('/\s*```$/m', '', trim($text));
|
|
$data = @json_decode($text, true);
|
|
if (!$data || !isset($data['commands'])) {
|
|
$first = strpos($text, '{');
|
|
$last = strrpos($text, '}');
|
|
if ($first !== false && $last !== false && $last > $first) {
|
|
$data = @json_decode(substr($text, $first, $last - $first + 1), true);
|
|
}
|
|
}
|
|
if (!$data || !isset($data['commands'])) {
|
|
$unesc = stripslashes($text);
|
|
$first = strpos($unesc, '{');
|
|
$last = strrpos($unesc, '}');
|
|
if ($first !== false && $last !== false) {
|
|
$data = @json_decode(substr($unesc, $first, $last - $first + 1), true);
|
|
}
|
|
}
|
|
return $data;
|
|
}
|
|
|
|
// =========================================================
|
|
// STEP 1 — INITIAL PLAN by WEVIA Master (Cerebras-think)
|
|
// =========================================================
|
|
sse('thinking', ['step' => 'plan', 'msg' => 'WEVIA Master génère plan initial...']);
|
|
|
|
$systemPlan = "Tu es WEVIA Master, agent autonome S204 (weval-consulting.com).
|
|
Capacités: bash, php, python3, curl, git, sudo NOPASSWD www-data.
|
|
Réponds UNIQUEMENT en JSON strict:
|
|
{\"plan\":[\"étape 1\",...],\"commands\":[\"cmd bash 1\",...],\"risk\":\"low|medium|high\",\"rationale\":\"pourquoi cette approche\"}
|
|
Commands courtes, lisibles, exécutables. Pas de rm -rf /, format, fork bombs, curl|bash.
|
|
Si dangereux: {\"plan\":[\"REFUSED: raison\"],\"commands\":[],\"risk\":\"high\"}.";
|
|
|
|
$plan = llmCall($systemPlan, "TÂCHE: $task\n\nPropose plan JSON.", 'cerebras-think', 1500);
|
|
if (!empty($plan['error'])) {
|
|
sse('error', ['msg' => 'Plan LLM error: ' . $plan['error']]);
|
|
exit;
|
|
}
|
|
|
|
$planData = parsePlanJson($plan['content']);
|
|
if (!$planData) {
|
|
sse('error', ['msg' => 'Plan parse failed', 'raw' => substr($plan['content'], 0, 400)]);
|
|
exit;
|
|
}
|
|
|
|
sse('plan', [
|
|
'plan' => $planData['plan'] ?? [],
|
|
'commands' => $planData['commands'] ?? [],
|
|
'risk' => $planData['risk'] ?? 'unknown',
|
|
'rationale' => $planData['rationale'] ?? null,
|
|
'model' => $plan['model'],
|
|
'provider' => $plan['provider']
|
|
]);
|
|
|
|
// =========================================================
|
|
// doctrine_178_autoconsensus: force consensus si plan risky
|
|
$dangerousKeywords = ["restart", "reboot", "systemctl", "service ", "apt ", "upgrade", "kill ", "rm -rf", "DROP ", "TRUNCATE", "mysql ", "nginx "];
|
|
$planText = strtolower(json_encode($planData));
|
|
$riskyDetected = [];
|
|
foreach ($dangerousKeywords as $kw) {
|
|
if (strpos($planText, strtolower($kw)) !== false) $riskyDetected[] = $kw;
|
|
}
|
|
if (!empty($riskyDetected) && !$consensus) {
|
|
$consensus = true;
|
|
sse("doctrine_178", ["msg" => "Keywords dangereux detectes - consensus force", "keywords" => $riskyDetected]);
|
|
}
|
|
|
|
// STEP 1.5 — CONSENSUS MODE: ask N IA for opinion
|
|
// =========================================================
|
|
$consensusResult = null;
|
|
if ($consensus) {
|
|
sse('thinking', ['step' => 'consensus', 'msg' => 'Demande avis aux ' . count($consensus_models) . ' IA souveraines...']);
|
|
|
|
$opinionSystem = "Tu es un expert sysadmin/devops consultant. Tu dois donner un avis sur un plan d'exécution proposé par un autre agent.
|
|
Réponds UNIQUEMENT en JSON strict:
|
|
{\"vote\":\"approve|reject|modify\",\"confidence\":0-10,\"concerns\":[\"souci 1\",\"souci 2\"],\"suggested_changes\":[\"modif cmd 1\",...] ou [],\"rationale\":\"explication courte\"}";
|
|
|
|
$opinionUser = "TÂCHE INITIALE:\n$task\n\nPLAN PROPOSÉ par WEVIA Master:\n" . json_encode($planData, JSON_PRETTY_PRINT) . "\n\nDonne ton avis structuré JSON.";
|
|
|
|
$opinions = [];
|
|
$approve = 0; $reject = 0; $modify = 0;
|
|
$allConcerns = []; $allSuggestions = [];
|
|
|
|
foreach ($consensus_models as $modelId) {
|
|
sse('consensus_query', ['model' => $modelId, 'msg' => "Interroge $modelId..."]);
|
|
$opn = llmCall($opinionSystem, $opinionUser, $modelId, 800, 15);
|
|
|
|
if (!empty($opn['error'])) {
|
|
$opinions[] = [
|
|
'model' => $modelId,
|
|
'provider' => null,
|
|
'vote' => 'error',
|
|
'error' => $opn['error']
|
|
];
|
|
sse('consensus_response', end($opinions));
|
|
continue;
|
|
}
|
|
|
|
$parsed = parsePlanJson($opn['content']);
|
|
if (!$parsed || !isset($parsed['vote'])) {
|
|
$opinions[] = [
|
|
'model' => $modelId,
|
|
'provider' => $opn['provider'],
|
|
'vote' => 'unparseable',
|
|
'raw' => substr($opn['content'], 0, 200)
|
|
];
|
|
sse('consensus_response', end($opinions));
|
|
continue;
|
|
}
|
|
|
|
$vote = strtolower(trim($parsed['vote']));
|
|
if ($vote === 'approve') $approve++;
|
|
elseif ($vote === 'reject') $reject++;
|
|
elseif ($vote === 'modify') $modify++;
|
|
|
|
$entry = [
|
|
'model' => $modelId,
|
|
'provider' => $opn['provider'],
|
|
'vote' => $vote,
|
|
'confidence' => $parsed['confidence'] ?? null,
|
|
'concerns' => $parsed['concerns'] ?? [],
|
|
'suggested_changes' => $parsed['suggested_changes'] ?? [],
|
|
'rationale' => substr($parsed['rationale'] ?? '', 0, 300)
|
|
];
|
|
$opinions[] = $entry;
|
|
|
|
foreach ($entry['concerns'] as $c) $allConcerns[] = $c;
|
|
foreach ($entry['suggested_changes'] as $s) $allSuggestions[] = $s;
|
|
|
|
sse('consensus_response', $entry);
|
|
}
|
|
|
|
// Vote tally
|
|
$totalVotes = $approve + $reject + $modify;
|
|
$decision = 'unknown';
|
|
if ($totalVotes === 0) {
|
|
$decision = 'no_quorum';
|
|
} elseif ($reject > $approve && $reject > $modify) {
|
|
$decision = 'rejected_by_majority';
|
|
} elseif ($modify >= $approve && $modify > 0) {
|
|
$decision = 'modify_recommended';
|
|
} elseif ($approve > 0 && $approve >= $reject) {
|
|
$decision = 'approved';
|
|
} else {
|
|
$decision = 'no_consensus';
|
|
}
|
|
|
|
$consensusResult = [
|
|
'opinions' => $opinions,
|
|
'tally' => ['approve' => $approve, 'reject' => $reject, 'modify' => $modify, 'total' => $totalVotes],
|
|
'decision' => $decision,
|
|
'concerns_aggregated' => array_slice(array_unique($allConcerns), 0, 8),
|
|
'suggestions_aggregated' => array_slice(array_unique($allSuggestions), 0, 8)
|
|
];
|
|
|
|
sse('consensus_decision', $consensusResult);
|
|
|
|
// === Honor decision ===
|
|
if ($decision === 'rejected_by_majority' || $decision === 'no_quorum') {
|
|
sse('aborted', [
|
|
'reason' => $decision,
|
|
'concerns' => $consensusResult['concerns_aggregated'],
|
|
'suggestions' => $consensusResult['suggestions_aggregated']
|
|
]);
|
|
sse('done', ['task' => $task, 'aborted' => true, 'reason' => $decision]);
|
|
exit;
|
|
}
|
|
|
|
// === If MODIFY recommended, ask WEVIA Master to revise plan ===
|
|
if ($decision === 'modify_recommended' && !empty($consensusResult['suggestions_aggregated'])) {
|
|
sse('thinking', ['step' => 'revise', 'msg' => 'Consensus = modify. WEVIA révise le plan avec les suggestions...']);
|
|
$reviseUser = "TÂCHE: $task\n\nPLAN ORIGINAL:\n" . json_encode($planData) . "\n\nRETOURS DES IA CONSULTANTES:\nConcerns: " . json_encode($consensusResult['concerns_aggregated']) . "\nSuggestions: " . json_encode($consensusResult['suggestions_aggregated']) . "\n\nRévise le plan en intégrant les suggestions. Réponds en JSON strict {plan,commands,risk,rationale}.";
|
|
$revised = llmCall($systemPlan, $reviseUser, 'cerebras-think', 1500);
|
|
$revisedData = parsePlanJson($revised['content'] ?? '');
|
|
if ($revisedData && !empty($revisedData['commands'])) {
|
|
$planData = $revisedData;
|
|
sse('plan_revised', [
|
|
'plan' => $planData['plan'] ?? [],
|
|
'commands' => $planData['commands'] ?? [],
|
|
'risk' => $planData['risk'] ?? 'unknown',
|
|
'integrated_suggestions' => count($consensusResult['suggestions_aggregated'])
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================
|
|
// STEP 2 — EXECUTE
|
|
// =========================================================
|
|
if ($dry_run) {
|
|
sse('done', ['msg' => 'DRY RUN - no execution', 'plan' => $planData, 'consensus' => $consensusResult]);
|
|
exit;
|
|
}
|
|
|
|
$results = [];
|
|
$commands = array_slice($planData['commands'] ?? [], 0, $max_steps);
|
|
foreach ($commands as $idx => $cmd) {
|
|
sse('exec_start', ['step' => $idx + 1, 'cmd' => $cmd]);
|
|
$result = execCmd($cmd, 25);
|
|
$results[] = $result;
|
|
sse('exec_result', [
|
|
'step' => $idx + 1,
|
|
'cmd' => $cmd,
|
|
'ok' => $result['ok'],
|
|
'output' => $result['output'] ?? null,
|
|
'error' => $result['error'] ?? null,
|
|
'duration_ms' => $result['duration_ms'] ?? null
|
|
]);
|
|
|
|
if (!$result['ok'] || (strpos($result['output'] ?? '', 'error') !== false && strpos($result['output'] ?? '', 'No such') !== false)) {
|
|
sse('recovery_thinking', ['msg' => 'Erreur, WEVIA propose fix...']);
|
|
$recoverPrompt = "Cmd échouée:\n$cmd\nOutput:\n" . substr($result['output'] ?? $result['error'], 0, 500) . "\n\nPropose UNE cmd bash de fix. UNIQUEMENT la commande.";
|
|
$recover = llmCall("Tu es WEVIA. Réponds UNIQUEMENT avec une cmd bash valide.", $recoverPrompt, 'cerebras-fast', 200);
|
|
$fixCmd = trim($recover['content'] ?? '');
|
|
$fixCmd = preg_replace('/^```(?:bash)?\s*|\s*```$/m', '', $fixCmd);
|
|
$fixCmd = trim(explode("\n", $fixCmd)[0]);
|
|
if ($fixCmd && strlen($fixCmd) < 500) {
|
|
sse('recovery_exec', ['fix_cmd' => $fixCmd]);
|
|
$fixResult = execCmd($fixCmd, 15);
|
|
sse('recovery_result', $fixResult);
|
|
$results[] = $fixResult;
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================
|
|
// STEP 3 — SUMMARIZE
|
|
// =========================================================
|
|
sse('thinking', ['step' => 'summarize', 'msg' => 'WEVIA résume...']);
|
|
|
|
$resultsText = '';
|
|
foreach ($results as $i => $r) {
|
|
$resultsText .= "Step " . ($i+1) . " cmd: " . substr($r['cmd'] ?? '', 0, 200) . "\nOutput: " . substr($r['output'] ?? $r['error'] ?? '', 0, 600) . "\n\n";
|
|
}
|
|
|
|
$summarizeContext = "Tâche: $task\n\nRésultats:\n$resultsText";
|
|
if ($consensusResult) {
|
|
$summarizeContext .= "\nConsensus: " . $consensusResult['decision'] . " (approve=" . $consensusResult['tally']['approve'] . " reject=" . $consensusResult['tally']['reject'] . " modify=" . $consensusResult['tally']['modify'] . ")";
|
|
}
|
|
|
|
$summarize = llmCall(
|
|
"Tu es WEVIA Master. Résume en français pro, concis. Indique succès/échec + 1-2 next actions.",
|
|
$summarizeContext,
|
|
'cerebras-fast',
|
|
600
|
|
);
|
|
|
|
sse('summary', [
|
|
'content' => $summarize['content'] ?? '(no summary)',
|
|
'model' => $summarize['model'],
|
|
'provider' => $summarize['provider']
|
|
]);
|
|
|
|
sse('done', [
|
|
'task' => $task,
|
|
'steps_executed' => count($results),
|
|
'success_count' => count(array_filter($results, fn($r) => $r['ok'] ?? false)),
|
|
'consensus_used' => $consensus,
|
|
'consensus_decision' => $consensusResult['decision'] ?? null
|
|
]);
|