Repository navigation
Expand file tree
/
Copy pathweb.php
More file actions
120 lines (106 loc) · 5.61 KB
/
Copy pathweb.php
File metadata and controls
120 lines (106 loc) · 5.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
<?php
declare(strict_types=1);
/*
* The request/response host idiom: each invocation is one request that runs one turn, prints its
* events as JSON lines (what an SSE or WebSocket route would forward) and keeps the conversation and
* the pending calls in a JSON file between requests. A write needs the user's approval; a tool can
* ask the user a question. A Compact built per request keeps the prompt within the context window,
* and its state (the calibration and the checkpoint) is kept in the file too, so the next request
* starts calibrated and serves the checkpoint without a side call.
*
* php examples/web.php state.json say "Remember that my favourite colour is blue"
* php examples/web.php state.json answer '{"<call id>": {"approval": "allow"}}' # or {"deny": "why"}
* php examples/web.php state.json answer '{"<call id>": {"answer": "blue"}}' # answers to questions, by need id
*/
require __DIR__ . '/../vendor/autoload.php';
use Harness\Answer;
use Harness\Approval;
use Harness\Call;
use Harness\ClosureTool;
use Harness\Compact\Compact;
use Harness\Compact\State;
use Harness\Decision;
use Harness\Hints;
use Harness\Http\StreamTransport;
use Harness\Message;
use Harness\Options;
use Harness\Part\Text;
use Harness\Part\ToolCall;
use Harness\Pending;
use Harness\Provider\OpenAI\ChatCompletions;
use Harness\Question;
use Harness\Result;
use Harness\Role;
use Harness\Run;
use Harness\ToolMiddleware;
use Harness\Tools;
use Harness\ToolSpec;
use Harness\Transcript\JsonlEvents;
// This repository installs two adapters for its tests; an application installs one and skips this line.
fiber\setup(new FiberInterop\Phasync\Adapter());
$url = \getenv('HARNESS_OPENAI_URL') ?: exit("Set HARNESS_OPENAI_URL (and HARNESS_MODEL)\n");
[, $file, $command, $arg] = $argv + [null, null, null, null];
if (null === $file || !\in_array($command, ['say', 'answer'], true) || null === $arg) {
exit("Usage: php examples/web.php <state.json> say <message> | answer <json>\n");
}
$state = \is_file($file) ? \json_decode(\file_get_contents($file), true, 512, \JSON_THROW_ON_ERROR) : ['messages' => [], 'pending' => [], 'notes' => [], 'compact' => null];
$ask = new ClosureTool(
new ToolSpec('ask_user', 'Ask the user a question and get the answer', ['type' => 'object', 'properties' => ['question' => ['type' => 'string']], 'required' => ['question']], new Hints(readOnly: true)),
fn (array $in, Call $call): Result => match (true) {
[] === $call->answers() => $call->needsInput([new Question('answer', $in['question'])]),
$call->answers()['answer'] instanceof Decision => Result::text("The user declined to answer: {$call->answers()['answer']->reason}"),
default => Result::text((string) $call->answers()['answer']),
},
);
$remember = new ClosureTool(
new ToolSpec('remember', 'Save a note for later conversations', ['type' => 'object', 'properties' => ['note' => ['type' => 'string']], 'required' => ['note']]),
function (array $in, Call $call) use (&$state): Result {
$state['notes'][] = $in['note'];
return Result::text('Saved');
},
);
// The policy, the only gate: the turn must end here, since the browser answers in a later request.
$policy = new class implements ToolMiddleware {
public function call(ToolCall $call, Call $context, Closure $next): Result
{
if ([] === $context->answers() && null !== $context->spec && !$context->spec->hints->readOnly) {
return $context->needsInput([new Approval(reason: "Save a note: {$call->input['note']}")]);
}
return $next($call, $context);
}
};
$model = new ChatCompletions(new StreamTransport(), $url, \getenv('HARNESS_MODEL') ?: 'default');
// Compacts at the context window less 15 %, with the run's own model (docs/compact.md).
$compact = new Compact(state: null === ($state['compact'] ?? null) ? null : State::fromArray($state['compact']));
$run = new Run(
$model,
new Tools($ask, $remember),
system: 'You are a brief assistant. Notes so far: ' . (\implode('; ', $state['notes']) ?: 'none') . '. Save new facts about the user with remember.',
middleware: [$policy, $compact],
options: new Options(maxOutputTokens: 1000, contextWindow: (int) (\getenv('HARNESS_CONTEXT_WINDOW') ?: 32768)),
messages: \array_map(Message::fromArray(...), $state['messages']),
pending: \array_map(Pending::fromArray(...), $state['pending']),
);
// The browser's answers, by call id and need id: "allow" for an approval, {"deny": "why"} for either
// kind, a value for a question.
$input = 'say' === $command
? new Message(Role::User, [new Text($arg)])
: \array_map(
fn (string $id, array $byNeed) => new Answer($id, \array_map(fn (mixed $a) => match (true) {
'allow' === $a => Decision::allow(),
\is_array($a) && isset($a['deny']) => Decision::deny($a['deny']),
default => $a,
}, $byNeed)),
\array_keys($answers = \json_decode($arg, true, 512, \JSON_THROW_ON_ERROR)),
$answers,
);
$sink = new JsonlEvents('php://stdout');
fiber\run(function () use ($run, $input, $sink) {
foreach ($run->turn($input) as $event) {
$sink->write($event);
}
});
$state['messages'] = \array_map(fn (Message $m) => $m->toArray(), $run->messages());
$state['pending'] = \array_map(fn (Pending $p) => $p->toArray(), $run->ended()->pending);
$state['compact'] = $compact->state()->toArray();
\file_put_contents($file, \json_encode($state, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR));