Let General Chat search the web, and only General Chat

Search is the one capability that fits the profile holding none: every other capability either
reads this installation or changes it, and chat's whole contract is that it can do neither. The
assistant deliberately does not get it — its contract is that answers come from this install's
own documents, and a web result there is an answer that looks sourced and is not. A chat question
about this machine hands off to the assistant before the search would run, so it never reaches
the internet even with the box ticked.

Off by default, and not because it is dangerous: searching sends the operator's question outside
the house, which is theirs to decide. Asked for per turn as well as enabled in conf.

Provider-agnostic, as asked — searxng, brave, tavily. Only whichever is configured here can be
verified; all three read every field by name, so a shape that changes underneath yields no
results rather than wrong ones.

The explain fixtures asserted chat holds no capabilities at all, which is exactly the guarantee
worth keeping. caps=only: now states the set rather than its emptiness, so the check still fails
the day something else is granted there.
This commit is contained in:
Gmer4Lfe
2026-08-09 22:42:43 -04:00
parent 47861b0dc3
commit 613634473a
10 changed files with 405 additions and 12 deletions
+55 -7
View File
@@ -121,8 +121,8 @@ if ($explain) {
exit(2);
}
} else {
[$jobFile, $question, $historyJson, $kind, $think, $profile, $scope] =
array_slice($argv, 1, 7) + array_fill(0, 7, '');
[$jobFile, $question, $historyJson, $kind, $think, $profile, $scope, $webArg] =
array_slice($argv, 1, 8) + array_fill(0, 8, '');
if ($jobFile === '' || $question === '') exit(1);
if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1);
@@ -197,6 +197,49 @@ $sources = [];
$context = '';
$tRetrieve = 0.0;
// ── Web search ───────────────────────────────────────────────────────────────────────────────
// Asked for per turn, never decided here. The operator ticks it, and it only exists on the one
// profile that holds the capability — which is General Chat, and only because chat cannot write.
//
// Placed ahead of retrieval so its results are numbered first and the citation numbers the model
// sees match the order the page lists them in. It is mutually exclusive with retrieval in
// practice rather than by rule: no profile holds both, because the assistant's contract is that
// its answers come from this installation's own documents.
//
// A handoff has already happened by this point if it was going to — a chat question about this
// machine has become a varaverk one, which does not hold web_search, so asking about Varaverk
// never reaches the internet even with the box ticked.
$webAsked = ($webArg ?? '') === '1';
if ($webAsked && $can('web_search')) {
require_once dirname(__DIR__) . '/include/ai_web.php';
$tw = microtime(true);
$web = vv_ai_web_search($question);
wlog(sprintf('web search provider=%s ok=%s results=%d %s(%dms)',
$web['provider'] ?? '?', ($web['ok'] ?? false) ? 'yes' : 'no',
count($web['results'] ?? []), isset($web['error']) ? '(' . $web['error'] . ') ' : '',
(int)((microtime(true) - $tw) * 1000)));
if (($web['ok'] ?? false) && $web['results']) {
$context .= vv_ai_web_context($web['results'], 0);
foreach ($web['results'] as $r) {
// path carries the URL so the existing citation wiring keeps working unchanged; url
// is what tells the page to open a browser tab instead of the source viewer.
$sources[] = ['path' => $r['url'], 'url' => $r['url'], 'section' => '',
'heading' => $r['title'], 'score' => 0, 'web' => true];
}
$attached['web_search'] = count($web['results']) . ' results';
} elseif (!($web['ok'] ?? false)) {
// Told to the model rather than swallowed. An assistant that searched and got nothing
// must not answer as though it had searched and found nothing exists.
$context .= "A web search was requested but did not run: " . ($web['error'] ?? 'unknown')
. ". Say so rather than answering as though the web had been consulted.\n\n";
$attached['web_search'] = 'failed: ' . ($web['error'] ?? 'unknown');
} else {
$context .= "A web search was run and returned no results. Say so.\n\n";
$attached['web_search'] = 'no results';
}
}
if ($can('retrieve')) {
jw($jobFile, ['status' => 'retrieving']);
@@ -233,14 +276,19 @@ if ($can('retrieve')) {
$tRetrieve = microtime(true) - $t0;
$sources = array_map(fn($x) => [
'path' => $x['path'] ?? '', 'section' => $x['section'] ?? '',
'heading' => $x['heading'] ?? '', 'score' => $x['score'] ?? 0,
], $r['results']);
// Appended, and numbered from whatever is already there. No profile holds both web_search and
// retrieve, so today this offset is always zero — but assigning over $sources and numbering
// from one would silently drop the other set the moment one ever does, and a citation
// pointing at the wrong source is worse than no citation.
$offset = count($sources);
foreach ($r['results'] as $x) {
$sources[] = ['path' => $x['path'] ?? '', 'section' => $x['section'] ?? '',
'heading' => $x['heading'] ?? '', 'score' => $x['score'] ?? 0];
}
foreach ($r['results'] as $i => $x) {
$label = implode(' ', array_filter([$x['path'] ?? '', $x['section'] ?? '', $x['heading'] ?? '']));
$context .= '[' . ($i + 1) . '] ' . $label . "\n" . trim($x['content'] ?? '') . "\n\n";
$context .= '[' . ($offset + $i + 1) . '] ' . $label . "\n" . trim($x['content'] ?? '') . "\n\n";
}
}
+13
View File
@@ -117,8 +117,21 @@ while IFS= read -r raw || [[ -n "$raw" ]]; do
exp=$([[ "$want" == "yes" ]] && echo YES || echo no)
[[ "$got_diag" == "$exp" ]] || problems+=("diagnostic: want $exp, got $got_diag") ;;
caps)
# none holds nothing at all
# only:a,b holds exactly these and nothing else
# a holds at least this
#
# only: exists because General Chat's guarantee is not "it can search" but "search
# is the only thing it can do". A presence check would still pass on the day
# something else is granted there, which is the day the check was written for.
if [[ "$want" == "none" ]]; then
[[ "$got_caps" == "(none"* ]] || problems+=("caps: want none, got $got_caps")
elif [[ "$want" == only:* ]]; then
want_set="${want#only:}"
got_set="$(printf '%s' "$got_caps" | tr -d ' ' | tr ',' '\n' | sort | paste -sd, -)"
exp_set="$(printf '%s' "$want_set" | tr -d ' ' | tr ',' '\n' | sort | paste -sd, -)"
[[ "$got_set" == "$exp_set" ]] \
|| problems+=("caps: want exactly [$exp_set], got [$got_set]")
else
[[ "$got_caps" == *"$want"* ]] || problems+=("caps: want $want in [$got_caps]")
fi ;;
+5 -3
View File
@@ -19,6 +19,8 @@
# run=yes|no the run-outcome gate
# diag=yes|no the diagnostic gate
# caps=none the profile holds no capabilities at all
# caps=only:a,b it holds exactly these and nothing else
# caps=a it holds at least this
# has=a,b every one of these must be attached
# hasnt=a,b none of these may be attached
#
@@ -70,7 +72,7 @@ Why did this run fail? | troubleshoot | Orchestrators/daily_sync_maintenance | |
How did this run go? | troubleshoot | Orchestrators/weekly_sync_maintenance | | has=log_tail,run_record
# ── General Chat holds nothing, and hands Varaverk questions up rather than deferring ──────────
how was your day | chat | | | profile=chat caps=none hasnt=health,log_tail,incidents,conf_keys
how was your day | chat | | | profile=chat caps=only:web_search hasnt=health,log_tail,incidents,conf_keys
what does arr_sync.sh do | chat | | | profile=varaverk
is RSYNC_ENABLED on right now | chat | | | profile=varaverk has=conf_keys
how did the daily orch go | chat | | | profile=varaverk run=yes has=run_record
@@ -99,8 +101,8 @@ write me a bash script for backups | chat | | | profile=code
# Diagnostic phrasing about nothing here stays in chat — "why" is not a Varaverk question on its
# own, and escalating adds capability, so a wrong escalation costs more than a missed one.
why is the sky blue | chat | | | profile=chat caps=none hasnt=health,log_tail,incidents,conf_keys
what is wrong with my car | chat | | | profile=chat caps=none
why is the sky blue | chat | | | profile=chat caps=only:web_search hasnt=health,log_tail,incidents,conf_keys
what is wrong with my car | chat | | | profile=chat caps=only:web_search
# Naming a script and asking what it does is documentation, not a request to write one. The code
# router is anchored on the verb for exactly this pair.