@alexkroman1/aai-cli 6.10.1 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
- package/dist/cli.mjs +30 -25
- package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
- package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
- package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
- package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +93 -14
- package/dist/scaffold/package.json +5 -3
- package/dist/scaffold/server.mjs +13 -4
- package/dist/scaffold/vite.config.ts +1 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
- package/dist/templates/call-audit/agent.test.ts +818 -0
- package/dist/templates/call-audit/agent.ts +171 -0
- package/dist/templates/call-audit/client.tsx +198 -0
- package/dist/templates/call-audit/workflows/audit.ts +298 -0
- package/dist/templates/call-audit/workflows/ingest.ts +251 -0
- package/dist/templates/call-audit/workflows/media.ts +637 -0
- package/dist/templates/call-audit/workflows/summarize.ts +201 -0
- package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
- package/dist/templates/dispatch-center/agent.test.ts +149 -25
- package/dist/templates/dispatch-center/client.tsx +239 -129
- package/dist/templates/dispatch-center/shared.ts +99 -1
- package/dist/templates/dispatch-center/system-prompt.md +3 -1
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
- package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
- package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.test.ts +4 -6
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- package/dist/templates/link-digest/agent.test.ts +24 -19
- package/dist/templates/link-digest/client.tsx +47 -61
- package/dist/templates/link-digest/workflows/digest.ts +19 -31
- package/dist/templates/night-owl/agent.test.ts +70 -19
- package/dist/templates/night-owl/agent.ts +5 -0
- package/dist/templates/night-owl/client.tsx +56 -56
- package/dist/templates/night-owl/shared.ts +24 -0
- package/dist/templates/night-owl/tools/recommend.ts +18 -6
- package/dist/templates/pizza-ordering/agent.test.ts +27 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- package/dist/templates/plan-and-execute/agent.test.ts +99 -58
- package/dist/templates/plan-and-execute/agent.ts +1 -1
- package/dist/templates/plan-and-execute/client.tsx +12 -15
- package/dist/templates/plan-and-execute/shared.ts +71 -2
- package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
- package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
- package/dist/templates/podcast-digest/agent.test.ts +746 -0
- package/dist/templates/podcast-digest/agent.ts +139 -0
- package/dist/templates/podcast-digest/client.tsx +154 -0
- package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
- package/dist/templates/recap-workflow/agent.test.ts +107 -96
- package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
- package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
- package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
- package/dist/templates/recap-workflow/workflows/recap.ts +48 -41
- package/dist/templates/redline/agent.test.ts +17 -15
- package/dist/templates/redline/client.tsx +12 -12
- package/dist/templates/redline/workflows/redline.ts +19 -31
- package/dist/templates/research-workflow/agent.test.ts +60 -59
- package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
- package/dist/templates/research-workflow/tools/research_status.ts +9 -11
- package/dist/templates/research-workflow/workflows/research.ts +44 -61
- package/dist/templates/retail/agent.test.ts +26 -23
- package/dist/templates/retail/client.tsx +226 -117
- package/dist/templates/retail/registry.test.ts +38 -6
- package/dist/templates/retail/store.test.ts +82 -15
- package/dist/templates/retail/store.ts +174 -47
- package/dist/templates/retail/system-prompt.md +11 -2
- package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
- package/dist/templates/retail/tools/get_item_details.ts +3 -4
- package/dist/templates/retail/tools/get_order_details.ts +3 -4
- package/dist/templates/retail/tools/get_product_details.ts +3 -4
- package/dist/templates/retail/tools/get_user_details.ts +2 -2
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
- package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
- package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
- package/dist/templates/retail/tools/modify_user_address.ts +2 -2
- package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
- package/dist/templates/solo-rpg/agent.test.ts +227 -48
- package/dist/templates/solo-rpg/client.tsx +38 -37
- package/dist/templates/solo-rpg/shared.ts +145 -19
- package/dist/templates/solo-rpg/system-prompt.md +3 -2
- package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
- package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
- package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
- package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
- package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
- package/dist/templates/spoken-summary/agent.test.ts +314 -0
- package/dist/templates/spoken-summary/agent.ts +155 -0
- package/dist/templates/spoken-summary/client.tsx +185 -0
- package/dist/templates/spoken-summary/workflows/summarize.ts +237 -0
- package/dist/templates/spoken-summary/workflows/transcribe.ts +138 -0
- package/dist/templates/support-line/agent.test.ts +11 -16
- package/dist/templates/support-line/agent.ts +1 -1
- package/dist/templates/support-line/client.tsx +9 -9
- package/dist/templates/support-line/nodes.ts +100 -0
- package/dist/templates/support-line/procedure.ts +407 -0
- package/dist/templates/support-line/tools/answer_question.ts +17 -4
- package/dist/templates/transcription-workflow/agent.test.ts +246 -123
- package/dist/templates/transcription-workflow/agent.ts +21 -7
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +79 -180
- package/dist/templates/transcription-workflow/workflows/normalize.ts +247 -0
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +10 -9
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +55 -40
- package/dist/templates/transcription-workflow/workflows/wav.ts +31 -31
- package/dist/templates/travel-concierge/agent.test.ts +64 -33
- package/dist/templates/travel-concierge/client.tsx +11 -23
- package/dist/templates/travel-concierge/routing.ts +34 -15
- package/dist/templates/travel-concierge/shared.ts +70 -3
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
- package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
- package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/templates/support-line/graph.ts +0 -224
- package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
- /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
|
@@ -7,83 +7,70 @@
|
|
|
7
7
|
* is the same — the same `client.tsx` filename, React, Tailwind, and the same
|
|
8
8
|
* theme tokens the voice components read.
|
|
9
9
|
*
|
|
10
|
-
* What replaces `useSession()` is
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* What replaces `useSession()` is `useWorkflowSubmit()`: it starts the run,
|
|
11
|
+
* follows its STATUS, and hands back the controls bound to it — `wake`, `cancel`
|
|
12
|
+
* and `reset`. The API is durable, so the `runId` is the whole state — it
|
|
13
|
+
* survives a reload, a different device, or `curl`.
|
|
14
|
+
*
|
|
15
|
+
* ## The FORM here is still written by hand, deliberately
|
|
16
|
+
*
|
|
17
|
+
* This is the template that shows the primitives raw. `redline` and
|
|
18
|
+
* `transcription-workflow` declare their forms — `<Form>` + `<WorkflowFields>`
|
|
19
|
+
* renders one control per scalar the schema declares — and that is what most
|
|
20
|
+
* pages should do. This one writes its single `<input>` itself, so a reader can
|
|
21
|
+
* see what the declared layer is standing on: an ordinary `onSubmit` handing an
|
|
22
|
+
* object to `submit()`.
|
|
14
23
|
*
|
|
15
24
|
* ## Status and progress are different questions
|
|
16
25
|
*
|
|
17
|
-
* `
|
|
18
|
-
* pending, running, completed.
|
|
19
|
-
* from what the run wrote itself (`report()` in `workflows/digest.ts`). A
|
|
20
|
-
* with only the first shows "Working…" for the length of the run; a page
|
|
21
|
-
* only the second cannot tell a finished run from a quiet one. Both are
|
|
22
|
-
* one stream each, ended by the agent when there is nothing left to say.
|
|
26
|
+
* `useWorkflowSubmit` answers "where has this got to" from the world's own
|
|
27
|
+
* record — pending, running, completed. `<WorkflowProgress>` answers "what is it
|
|
28
|
+
* doing" from what the run wrote itself (`report()` in `workflows/digest.ts`). A
|
|
29
|
+
* page with only the first shows "Working…" for the length of the run; a page
|
|
30
|
+
* with only the second cannot tell a finished run from a quiet one. Both are
|
|
31
|
+
* cheap: one stream each, ended by the agent when there is nothing left to say.
|
|
23
32
|
*
|
|
24
33
|
* Progress also REPLAYS — chunks are retained with the run — so a reload mid-run
|
|
25
|
-
* catches up rather than starting from whatever arrives next.
|
|
26
|
-
*
|
|
27
|
-
* status wants; `transcription-workflow` renders the full log,
|
|
28
|
-
* the history worth seeing.
|
|
34
|
+
* catches up rather than starting from whatever arrives next. `lines={1}` is
|
|
35
|
+
* what narrows it to the newest line, because on a page this small that is the
|
|
36
|
+
* whole of what a status wants; `transcription-workflow` renders the full log,
|
|
37
|
+
* where a fan-out makes the history worth seeing.
|
|
29
38
|
*/
|
|
30
39
|
|
|
31
|
-
import {
|
|
40
|
+
import { page, useWorkflowSubmit, WorkflowProgress } from "@alexkroman1/aai-ui";
|
|
32
41
|
import "@alexkroman1/aai-ui/styles.css";
|
|
33
42
|
// ERASED at build time, so naming the agent's own type costs the browser bundle
|
|
34
43
|
// nothing — and it is what stops this file restating a shape `workflows/
|
|
35
44
|
// digest.ts` already declares.
|
|
36
|
-
import type { WorkflowOutputOf } from "@alexkroman1/aai";
|
|
37
|
-
// The one runtime import from the SDK a browser bundle wants: `/utils` is the
|
|
38
|
-
// zod-free subpath, so it costs a few hundred bytes rather than the root
|
|
39
|
-
// barrel's module graph.
|
|
40
|
-
import { errorMessage } from "@alexkroman1/aai/utils";
|
|
45
|
+
import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
|
|
41
46
|
import { useState } from "react";
|
|
42
47
|
import type { digest } from "./agent.ts";
|
|
43
48
|
|
|
44
49
|
/** What a completed run reports, derived from the workflow rather than restated. */
|
|
45
50
|
type Digest = WorkflowOutputOf<typeof digest>;
|
|
46
51
|
|
|
47
|
-
/**
|
|
48
|
-
* Hoisted out of the component deliberately.
|
|
49
|
-
*
|
|
50
|
-
* `useWorkflowRun` holds the client in a ref precisely so a fresh object per
|
|
51
|
-
* render cannot restart its watch, but building one in render is still a new
|
|
52
|
-
* `fetch` closure every time and reads as though it were free.
|
|
53
|
-
*/
|
|
54
|
-
const api = createWorkflowApi();
|
|
55
|
-
|
|
56
52
|
export function App() {
|
|
57
53
|
const [url, setUrl] = useState("");
|
|
58
|
-
const [runId, setRunId] = useState<string>();
|
|
59
|
-
const [error, setError] = useState<string>();
|
|
60
54
|
// The generic is what makes `run.status === "completed"` narrow to a TYPED
|
|
61
|
-
// `run.output` instead of `unknown`.
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
const {
|
|
55
|
+
// `run.output` instead of `unknown`. `error` is the agent's own sentence for a
|
|
56
|
+
// rejected input, which is better copy than anything this page could write, and
|
|
57
|
+
// `wake` is bound to whatever run the hook is following — the whole reason this
|
|
58
|
+
// page no longer holds a `createWorkflowApi()` of its own.
|
|
59
|
+
const { submit, run, pending, error, wake } = useWorkflowSubmit<Digest>("digest");
|
|
66
60
|
|
|
67
|
-
|
|
61
|
+
// `submit()` resolves as soon as the run exists — deliberately not when it
|
|
62
|
+
// finishes. That is the whole mechanism: the digest sleeps for a while, and
|
|
63
|
+
// this page is free to be closed in the meantime.
|
|
64
|
+
const onSubmit = (event: React.FormEvent) => {
|
|
68
65
|
event.preventDefault();
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
// Resolves as soon as the run exists — deliberately not when it finishes.
|
|
72
|
-
// That is the whole mechanism: the digest sleeps for a while, and this
|
|
73
|
-
// page is free to be closed in the meantime.
|
|
74
|
-
setRunId(await api.start("digest", { url }));
|
|
75
|
-
} catch (err) {
|
|
76
|
-
// The agent's own sentence: an input failing the workflow's schema names
|
|
77
|
-
// the issue, which is better copy than anything this page could write.
|
|
78
|
-
setError(errorMessage(err));
|
|
79
|
-
}
|
|
66
|
+
void submit({ url });
|
|
80
67
|
};
|
|
81
68
|
|
|
82
69
|
return (
|
|
83
70
|
<main className="mx-auto flex max-w-2xl flex-col gap-6 p-8">
|
|
84
71
|
<h1 className="text-2xl font-medium">Link Digest</h1>
|
|
85
72
|
|
|
86
|
-
<form onSubmit={
|
|
73
|
+
<form onSubmit={onSubmit} className="flex gap-2">
|
|
87
74
|
<input
|
|
88
75
|
type="url"
|
|
89
76
|
required
|
|
@@ -92,34 +79,33 @@ export function App() {
|
|
|
92
79
|
placeholder="https://example.com/article"
|
|
93
80
|
className="flex-1 rounded-md border px-3 py-2"
|
|
94
81
|
/>
|
|
95
|
-
<button type="submit" disabled={
|
|
96
|
-
{
|
|
82
|
+
<button type="submit" disabled={pending} className="rounded-md border px-4 py-2">
|
|
83
|
+
{pending ? "Working…" : "Digest"}
|
|
97
84
|
</button>
|
|
98
85
|
</form>
|
|
99
86
|
|
|
100
87
|
{error !== undefined && <p className="text-red-600">{error}</p>}
|
|
101
88
|
|
|
102
|
-
{/* A run that has not settled says so. `
|
|
89
|
+
{/* A run that has not settled says so. `pending` is not derivable from the
|
|
103
90
|
snapshot alone — an id the agent never knew leaves `run` undefined,
|
|
104
91
|
which would otherwise read as "still waiting" forever. */}
|
|
105
|
-
{
|
|
106
|
-
|
|
107
|
-
{/* The run's own narration, newest line only — see the module doc.
|
|
92
|
+
{pending && <p>You can close this tab — the run continues without it.</p>}
|
|
108
93
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
94
|
+
{/* The run's own narration, newest line only. `lines={1}` is the window;
|
|
95
|
+
everything else — the replay, and the "serves no stream" case that is
|
|
96
|
+
otherwise indistinguishable from "wrote nothing yet" — belongs to the
|
|
97
|
+
component. */}
|
|
98
|
+
<WorkflowProgress runId={run?.runId} lines={1} className="text-sm opacity-70" />
|
|
113
99
|
|
|
114
100
|
{/* The counterpart of the `sleep` in `workflows/digest.ts`. Without it the
|
|
115
101
|
only handle on a sleeping run is `cancel`, so "file it now" and "throw
|
|
116
102
|
it away" would be the same button. `wake` answering 0 means the run had
|
|
117
103
|
already moved past its wait, which is why nothing here treats that as a
|
|
118
104
|
failure. */}
|
|
119
|
-
{
|
|
105
|
+
{pending && (
|
|
120
106
|
<button
|
|
121
107
|
type="button"
|
|
122
|
-
onClick={() => void
|
|
108
|
+
onClick={() => void wake()}
|
|
123
109
|
className="self-start rounded-md border px-3 py-1 text-sm"
|
|
124
110
|
>
|
|
125
111
|
File it now
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
* fetched text crosses a queue between them, which is what the cap on it is for.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
27
|
+
import { report } from "@alexkroman1/aai/step";
|
|
28
|
+
import { stepFetchOk, stepGenerateJsonClassified } from "@alexkroman1/aai/step-errors";
|
|
29
|
+
import { decodeHtmlEntities } from "@alexkroman1/aai/utils";
|
|
29
30
|
import { FatalError, sleep } from "workflow";
|
|
30
31
|
import { z } from "zod";
|
|
31
32
|
|
|
@@ -120,18 +121,18 @@ export async function fetchArticle(url: string): Promise<Article> {
|
|
|
120
121
|
// reset with no HTTP status, which `toStepError` below has nothing to read.
|
|
121
122
|
// It also reports a connection failure with its whole `cause` chain instead
|
|
122
123
|
// of a bare `TypeError: fetch failed`. Redirects are followed by default.
|
|
123
|
-
|
|
124
|
+
// `stepFetchOk` rather than `stepFetch` + an `ok` check: it makes the
|
|
125
|
+
// retryable/terminal split for us — a 404 or a 403 answers the same way on
|
|
126
|
+
// the fourth attempt, while a rate limit is exactly what retries are for, and
|
|
127
|
+
// its `Retry-After` reaches the DevKit's schedule instead of the default
|
|
128
|
+
// backoff. It also puts the server's own error text in the message.
|
|
129
|
+
const response = await stepFetchOk(url, {
|
|
124
130
|
// Some sites answer a bare request with a challenge page; asking for HTML
|
|
125
131
|
// at least says what we want. Nothing here defeats a real bot wall, and a
|
|
126
132
|
// template pretending otherwise would be the dishonest version.
|
|
127
133
|
headers: { Accept: "text/html,application/xhtml+xml" },
|
|
128
134
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
129
135
|
});
|
|
130
|
-
// The retryable/terminal split, for the page we were pointed at: a 404 or a
|
|
131
|
-
// 403 answers the same way on the fourth attempt, while a rate limit is
|
|
132
|
-
// exactly what retries are for — and `toStepError` is what carries its
|
|
133
|
-
// `Retry-After` into the DevKit's schedule instead of the default backoff.
|
|
134
|
-
if (!response.ok) throw toStepError(response, `GET ${url} failed: HTTP ${response.status}`);
|
|
135
136
|
|
|
136
137
|
const html = await response.text();
|
|
137
138
|
const text = extractText(html);
|
|
@@ -159,12 +160,13 @@ export async function summarize(article: Article): Promise<Digest> {
|
|
|
159
160
|
|
|
160
161
|
await report("Pulling out the claims worth keeping.");
|
|
161
162
|
|
|
162
|
-
// `
|
|
163
|
-
// and validates it against `DigestReply` — and throws PLAINLY when
|
|
164
|
-
// those misses, which is the whole retry policy in one distinction: a
|
|
165
|
-
// that answered with prose may answer correctly on the next attempt,
|
|
166
|
-
// 401 will not. `
|
|
167
|
-
|
|
163
|
+
// `stepGenerateJsonClassified` unwraps the fence a model puts around JSON,
|
|
164
|
+
// parses it, and validates it against `DigestReply` — and throws PLAINLY when
|
|
165
|
+
// any of those misses, which is the whole retry policy in one distinction: a
|
|
166
|
+
// model that answered with prose may answer correctly on the next attempt,
|
|
167
|
+
// where a 401 will not. The `Classified` suffix is what makes the 401 half
|
|
168
|
+
// terminal: it is `stepGenerateJson` with `throwStepError` already applied.
|
|
169
|
+
const parsed = await stepGenerateJsonClassified(
|
|
168
170
|
`Title: ${article.title}\nURL: ${article.url}\n\n${article.text}`,
|
|
169
171
|
{
|
|
170
172
|
schema: DigestReply,
|
|
@@ -172,7 +174,7 @@ export async function summarize(article: Article): Promise<Digest> {
|
|
|
172
174
|
`You digest articles. Reply with JSON only: {"headline": string, "points": string[]}. ` +
|
|
173
175
|
`Give exactly ${POINTS} points. No markdown fence, no preamble.`,
|
|
174
176
|
},
|
|
175
|
-
)
|
|
177
|
+
);
|
|
176
178
|
|
|
177
179
|
return {
|
|
178
180
|
url: article.url,
|
|
@@ -208,7 +210,7 @@ export async function file(_digest: Digest): Promise<string> {
|
|
|
208
210
|
/** The document's `<title>`, when it has one. */
|
|
209
211
|
export function extractTitle(html: string): string | undefined {
|
|
210
212
|
const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1];
|
|
211
|
-
return title ?
|
|
213
|
+
return title ? decodeHtmlEntities(title).replace(/\s+/g, " ").trim() || undefined : undefined;
|
|
212
214
|
}
|
|
213
215
|
|
|
214
216
|
/**
|
|
@@ -222,7 +224,7 @@ export function extractTitle(html: string): string | undefined {
|
|
|
222
224
|
* reader.
|
|
223
225
|
*/
|
|
224
226
|
export function extractText(html: string): string {
|
|
225
|
-
return
|
|
227
|
+
return decodeHtmlEntities(
|
|
226
228
|
html
|
|
227
229
|
.replace(/<(script|style|noscript|template)[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
|
228
230
|
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
@@ -232,17 +234,3 @@ export function extractText(html: string): string {
|
|
|
232
234
|
.trim()
|
|
233
235
|
.slice(0, MAX_ARTICLE_CHARS);
|
|
234
236
|
}
|
|
235
|
-
|
|
236
|
-
/** The five entities that survive tag-stripping often enough to matter. */
|
|
237
|
-
function decodeEntities(text: string): string {
|
|
238
|
-
return (
|
|
239
|
-
text
|
|
240
|
-
.replace(/</g, "<")
|
|
241
|
-
.replace(/>/g, ">")
|
|
242
|
-
.replace(/"/g, '"')
|
|
243
|
-
.replace(/�?39;|'/g, "'")
|
|
244
|
-
.replace(/ /g, " ")
|
|
245
|
-
// `&` LAST, or `&lt;` decodes twice into a `<` the page never had.
|
|
246
|
-
.replace(/&/g, "&")
|
|
247
|
-
);
|
|
248
|
-
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/// <reference types="vite/client" />
|
|
2
2
|
|
|
3
3
|
import { toAgentConfig } from "@alexkroman1/aai/manifest";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createToolContext,
|
|
6
|
+
parseToolInput,
|
|
7
|
+
toolInputIssues,
|
|
8
|
+
toolRunner,
|
|
9
|
+
withDiscoveredTools,
|
|
10
|
+
} from "@alexkroman1/aai/testing";
|
|
5
11
|
import { describe, expect, test } from "vitest";
|
|
6
12
|
import authoredAgent from "./agent.ts";
|
|
7
|
-
import { CATEGORIES, MOODS } from "./shared.ts";
|
|
13
|
+
import { CATEGORIES, MOODS, nightProjection, nightSlot } from "./shared.ts";
|
|
8
14
|
|
|
9
15
|
/**
|
|
10
16
|
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
|
|
@@ -17,6 +23,14 @@ const agentDef = withDiscoveredTools(
|
|
|
17
23
|
import.meta.glob("./tools/*.ts", { eager: true }),
|
|
18
24
|
);
|
|
19
25
|
|
|
26
|
+
/**
|
|
27
|
+
* `runTool` takes the context in the ARGUMENTS' place when a tool needs none,
|
|
28
|
+
* so `toolRunner`'s second parameter takes either — which is why it is one
|
|
29
|
+
* signature rather than an overload pair. An omitted context is a fresh one,
|
|
30
|
+
* i.e. a distinct session with an empty slot.
|
|
31
|
+
*/
|
|
32
|
+
const run = toolRunner(agentDef);
|
|
33
|
+
|
|
20
34
|
describe("night-owl template", () => {
|
|
21
35
|
test("config passes manifest validation", () => {
|
|
22
36
|
// Same conversion `aai build`/`aai deploy` run.
|
|
@@ -33,22 +47,53 @@ describe("night-owl template", () => {
|
|
|
33
47
|
test("recommend is discovered from tools/", () => {
|
|
34
48
|
expect(Object.keys(agentDef.tools ?? {})).toEqual(["recommend"]);
|
|
35
49
|
});
|
|
50
|
+
|
|
51
|
+
test("the projection an untouched session pushes is an empty log", () => {
|
|
52
|
+
// What `useAgentState(nightProjection)` reads before the first tool call —
|
|
53
|
+
// derived from the slot's own default rather than guessed at in the page.
|
|
54
|
+
expect(nightProjection()).toEqual({ recs: [] });
|
|
55
|
+
});
|
|
36
56
|
});
|
|
37
57
|
|
|
38
58
|
describe("recommend", () => {
|
|
39
59
|
test("answers with picks for the category and mood asked for", async () => {
|
|
40
|
-
const
|
|
41
|
-
const result = await runTool(agentDef, "recommend", { category: "movie", mood: "cozy" }, ctx);
|
|
60
|
+
const result = await run("recommend", { category: "movie", mood: "cozy" });
|
|
42
61
|
expect(result).toMatchObject({ category: "movie", mood: "cozy" });
|
|
43
62
|
expect((result as { picks: string[] }).picks.length).toBeGreaterThan(0);
|
|
44
63
|
});
|
|
45
64
|
|
|
46
|
-
test("
|
|
47
|
-
//
|
|
48
|
-
//
|
|
65
|
+
test("the picks land in the session's own log, newest first", async () => {
|
|
66
|
+
// The log is STATE, not an event stream: `syncState` pushes this projection
|
|
67
|
+
// after every tool call, so a page that reloads mid-session resumes with it
|
|
68
|
+
// rather than starting empty.
|
|
69
|
+
const ctx = createToolContext();
|
|
70
|
+
const first = await run("recommend", { category: "book", mood: "spooky" }, ctx);
|
|
71
|
+
const second = await run("recommend", { category: "music", mood: "chill" }, ctx);
|
|
72
|
+
expect(nightProjection(nightSlot.get(ctx))).toEqual({ recs: [second, first] });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("two calls with no shared context are two sessions", async () => {
|
|
76
|
+
// The other half of the same rule, and the one that bites: an omitted
|
|
77
|
+
// context is a FRESH session, so nothing accumulates across these calls.
|
|
49
78
|
const ctx = createToolContext();
|
|
50
|
-
|
|
51
|
-
expect(ctx.
|
|
79
|
+
await run("recommend", { category: "book", mood: "cozy" });
|
|
80
|
+
expect(nightSlot.get(ctx).recs).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the wind-down nudge is sent once, on the third pick", async () => {
|
|
84
|
+
// A moment rather than state, which is why it is a `ctx.send` the page
|
|
85
|
+
// consumes with `useEvent` and not a field on the projection: re-delivering
|
|
86
|
+
// it on every reconnect would be nagging.
|
|
87
|
+
const ctx = createToolContext();
|
|
88
|
+
await run("recommend", { category: "movie", mood: "cozy" }, ctx);
|
|
89
|
+
await run("recommend", { category: "music", mood: "cozy" }, ctx);
|
|
90
|
+
expect(ctx.sent).toEqual([]);
|
|
91
|
+
await run("recommend", { category: "book", mood: "cozy" }, ctx);
|
|
92
|
+
expect(ctx.sent).toEqual([
|
|
93
|
+
{ event: "wind_down", data: "Three picks in. Want me to work out your bedtime?" },
|
|
94
|
+
]);
|
|
95
|
+
await run("recommend", { category: "movie", mood: "chill" }, ctx);
|
|
96
|
+
expect(ctx.sent).toHaveLength(1);
|
|
52
97
|
});
|
|
53
98
|
|
|
54
99
|
test("every category/mood pair the schema admits has picks behind it", async () => {
|
|
@@ -58,23 +103,29 @@ describe("recommend", () => {
|
|
|
58
103
|
// package's guide records three shipped tools having.
|
|
59
104
|
for (const category of CATEGORIES) {
|
|
60
105
|
for (const mood of MOODS) {
|
|
61
|
-
const result = await
|
|
62
|
-
agentDef,
|
|
63
|
-
"recommend",
|
|
64
|
-
{ category, mood },
|
|
65
|
-
createToolContext(),
|
|
66
|
-
);
|
|
106
|
+
const result = await run("recommend", { category, mood });
|
|
67
107
|
expect((result as { picks: string[] }).picks, `${category}/${mood}`).not.toHaveLength(0);
|
|
68
108
|
}
|
|
69
109
|
}
|
|
70
110
|
});
|
|
71
111
|
|
|
112
|
+
test("the schema accepts a category/mood pair from the enums", async () => {
|
|
113
|
+
const parsed = await parseToolInput<{ category: string; mood: string }>(agentDef, "recommend", {
|
|
114
|
+
category: "movie",
|
|
115
|
+
mood: "cozy",
|
|
116
|
+
});
|
|
117
|
+
expect(parsed).toEqual({ category: "movie", mood: "cozy" });
|
|
118
|
+
});
|
|
119
|
+
|
|
72
120
|
test("a mood outside the enum is refused by the schema", async () => {
|
|
73
121
|
// The wire boundary: an LLM tool call is untyped, so the schema is the only
|
|
74
122
|
// thing between a hallucinated mood and an index into `undefined`.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
123
|
+
// `toolInputIssues` is the SDK's own ask — `~standard` is a vendor wire
|
|
124
|
+
// contract, and the detail a hand-rolled version gets wrong first is that
|
|
125
|
+
// `.validate` may be sync or async, so a missing `await` leaves `.issues`
|
|
126
|
+
// undefined and the negative test passes for the wrong reason.
|
|
127
|
+
expect(
|
|
128
|
+
await toolInputIssues(agentDef, "recommend", { category: "movie", mood: "melancholy" }),
|
|
129
|
+
).toBeDefined();
|
|
79
130
|
});
|
|
80
131
|
});
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { agent } from "@alexkroman1/aai";
|
|
2
|
+
import { nightProjection } from "./shared.ts";
|
|
2
3
|
|
|
3
4
|
export default agent({
|
|
4
5
|
name: "Night Owl",
|
|
6
|
+
// The night's recommendation log, pushed to the client after every tool
|
|
7
|
+
// call. The page renders `useAgentState(nightProjection)` and keeps no copy
|
|
8
|
+
// of its own, so a reload resumes with the list intact.
|
|
9
|
+
syncState: nightProjection,
|
|
5
10
|
greeting:
|
|
6
11
|
"Hey there, night owl. Try asking me for a cozy movie recommendation, or tell me what time you need to wake up and I'll calculate the best time to fall asleep.",
|
|
7
12
|
builtinTools: ["run_code"],
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import "@alexkroman1/aai-ui/styles.css";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Two kinds of thing arrive from the agent, and this page keeps them apart.
|
|
4
|
+
*
|
|
5
|
+
* The recommendation LOG is state: the agent owns it in a `sessionSlot`,
|
|
6
|
+
* `syncState` projects it, and `useAgentState(nightProjection)` reads it. The
|
|
7
|
+
* page stores no copy, so a reload resumes with every pick still there. That is
|
|
8
|
+
* the pattern to reach for by default — see `pizza-ordering` for the same shape
|
|
9
|
+
* over a shopping cart.
|
|
10
|
+
*
|
|
11
|
+
* The "recommending…" flash and the wind-down nudge are MOMENTS. Neither is
|
|
12
|
+
* worth storing and neither should replay: a spinner for a call that finished
|
|
13
|
+
* before this component mounted would be a lie, and a nudge re-shown on every
|
|
14
|
+
* reconnect is nagging. `useToolCallStart` and `useEvent` are for exactly this —
|
|
15
|
+
* they fire once, carry no history, and drive throwaway `useState`.
|
|
16
|
+
*/
|
|
17
|
+
import { Button, client, useAgentState, useEvent, useToolCallStart } from "@alexkroman1/aai-ui";
|
|
18
|
+
import { useEffect, useState } from "react";
|
|
19
|
+
import { MOODS, nightProjection } from "./shared.ts";
|
|
7
20
|
|
|
8
21
|
const MOOD_EMOJI: Record<string, string> = {
|
|
9
22
|
chill: "\u{1F60C}",
|
|
@@ -19,38 +32,30 @@ const CAT_EMOJI: Record<string, string> = {
|
|
|
19
32
|
};
|
|
20
33
|
|
|
21
34
|
function RecSidebar() {
|
|
22
|
-
|
|
23
|
-
const
|
|
35
|
+
// State: the agent's own log. No `useState` mirror, no event diffing.
|
|
36
|
+
const { recs } = useAgentState(nightProjection);
|
|
37
|
+
// Page-local view state — which mood chip is pressed. Nothing to sync.
|
|
24
38
|
const [activeMood, setActiveMood] = useState<string | null>(null);
|
|
39
|
+
// Moments. Both are cleared by this page, never re-delivered by the session.
|
|
40
|
+
const [pendingMood, setPendingMood] = useState<string | null>(null);
|
|
41
|
+
const [nudge, setNudge] = useState<string | null>(null);
|
|
25
42
|
|
|
26
|
-
useToolCallStart("recommend", (tc) =>
|
|
27
|
-
|
|
28
|
-
});
|
|
43
|
+
useToolCallStart("recommend", (tc) => setPendingMood(String(tc.args.mood)));
|
|
44
|
+
useEvent<string>("wind_down", (text) => setNudge(text));
|
|
29
45
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
// The flash ends when the picks land, which is the projection changing —
|
|
47
|
+
// the same signal the list itself renders from.
|
|
48
|
+
useEffect(() => setPendingMood(null), [recs.length]);
|
|
33
49
|
|
|
34
50
|
const filtered = activeMood ? recs.filter((r) => r.mood === activeMood) : recs;
|
|
35
51
|
|
|
36
52
|
return (
|
|
37
|
-
<div
|
|
38
|
-
className="
|
|
39
|
-
|
|
40
|
-
>
|
|
41
|
-
<div className="px-4 py-3 border-b shrink-0" style={{ borderColor: theme.border }}>
|
|
42
|
-
<h2
|
|
43
|
-
className="text-xs font-bold uppercase tracking-wide opacity-60"
|
|
44
|
-
style={{ color: theme.text }}
|
|
45
|
-
>
|
|
46
|
-
Recommendations
|
|
47
|
-
</h2>
|
|
53
|
+
<div className="flex flex-col h-full text-sm bg-aai-bg text-aai-text">
|
|
54
|
+
<div className="px-4 py-3 border-b border-aai-border shrink-0">
|
|
55
|
+
<h2 className="text-xs font-bold uppercase tracking-wide opacity-60">Recommendations</h2>
|
|
48
56
|
</div>
|
|
49
57
|
|
|
50
|
-
<div
|
|
51
|
-
className="flex flex-wrap gap-1.5 px-3 py-2.5 border-b shrink-0"
|
|
52
|
-
style={{ borderColor: theme.border }}
|
|
53
|
-
>
|
|
58
|
+
<div className="flex flex-wrap gap-1.5 px-3 py-2.5 border-b border-aai-border shrink-0">
|
|
54
59
|
{MOODS.map((mood) => (
|
|
55
60
|
<Button
|
|
56
61
|
key={mood}
|
|
@@ -62,60 +67,55 @@ function RecSidebar() {
|
|
|
62
67
|
))}
|
|
63
68
|
</div>
|
|
64
69
|
|
|
70
|
+
{nudge && (
|
|
71
|
+
<div className="flex items-center gap-2 px-3 py-2 border-b border-aai-border shrink-0">
|
|
72
|
+
<p className="flex-1 text-xs text-aai-primary">{nudge}</p>
|
|
73
|
+
<Button variant="ghost" onClick={() => setNudge(null)}>
|
|
74
|
+
Dismiss
|
|
75
|
+
</Button>
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
|
|
65
79
|
<div className="flex-1 overflow-y-auto px-3 py-2">
|
|
66
|
-
{
|
|
67
|
-
<p className="text-xs
|
|
80
|
+
{pendingMood && (
|
|
81
|
+
<p className="text-xs py-2 opacity-60 animate-pulse">
|
|
82
|
+
Finding something {pendingMood}…
|
|
83
|
+
</p>
|
|
84
|
+
)}
|
|
85
|
+
{filtered.length === 0 && !pendingMood && (
|
|
86
|
+
<p className="text-xs text-center py-8 opacity-40">
|
|
68
87
|
Ask me to recommend a movie, album, or book
|
|
69
88
|
</p>
|
|
70
89
|
)}
|
|
71
90
|
{filtered.map((rec, i) => (
|
|
72
91
|
<div
|
|
73
92
|
key={`${rec.category}-${rec.mood}-${i}`}
|
|
74
|
-
className="mb-3 p-2.5 rounded-lg border"
|
|
75
|
-
style={{ background: theme.surface, borderColor: theme.border }}
|
|
93
|
+
className="mb-3 p-2.5 rounded-lg border border-aai-border bg-aai-surface"
|
|
76
94
|
>
|
|
77
95
|
<div className="flex items-center gap-2 mb-1.5">
|
|
78
96
|
<span className="text-xs">{CAT_EMOJI[rec.category]}</span>
|
|
79
|
-
<span className="text-xs font-semibold capitalize
|
|
97
|
+
<span className="text-xs font-semibold capitalize text-aai-primary">
|
|
80
98
|
{rec.category}s
|
|
81
99
|
</span>
|
|
82
|
-
<span className="text-xs capitalize opacity-50"
|
|
83
|
-
{rec.mood}
|
|
84
|
-
</span>
|
|
100
|
+
<span className="text-xs capitalize opacity-50">{rec.mood}</span>
|
|
85
101
|
</div>
|
|
86
102
|
{rec.picks.map((pick) => (
|
|
87
|
-
<p
|
|
88
|
-
key={pick}
|
|
89
|
-
className="text-xs pl-5 py-0.5 opacity-80"
|
|
90
|
-
style={{ color: theme.text }}
|
|
91
|
-
>
|
|
103
|
+
<p key={pick} className="text-xs pl-5 py-0.5 opacity-80">
|
|
92
104
|
{pick}
|
|
93
105
|
</p>
|
|
94
106
|
))}
|
|
95
107
|
</div>
|
|
96
108
|
))}
|
|
97
109
|
</div>
|
|
98
|
-
|
|
99
|
-
{recs.length > 0 && (
|
|
100
|
-
<div className="px-3 py-2 border-t shrink-0" style={{ borderColor: theme.border }}>
|
|
101
|
-
<Button
|
|
102
|
-
variant="ghost"
|
|
103
|
-
className="w-full"
|
|
104
|
-
onClick={() => {
|
|
105
|
-
setRecs([]);
|
|
106
|
-
setActiveMood(null);
|
|
107
|
-
}}
|
|
108
|
-
>
|
|
109
|
-
Clear
|
|
110
|
-
</Button>
|
|
111
|
-
</div>
|
|
112
|
-
)}
|
|
113
110
|
</div>
|
|
114
111
|
);
|
|
115
112
|
}
|
|
116
113
|
|
|
117
114
|
client({
|
|
118
115
|
name: "Night Owl",
|
|
116
|
+
icon: "\u{1F989}",
|
|
117
|
+
subtitle: "A cozy companion for the small hours",
|
|
118
|
+
buttonText: "Settle in",
|
|
119
119
|
sidebar: RecSidebar,
|
|
120
120
|
theme: {
|
|
121
121
|
bg: "#0c0e1a",
|
|
@@ -1,5 +1,29 @@
|
|
|
1
|
+
import { sessionSlot } from "@alexkroman1/aai";
|
|
2
|
+
|
|
1
3
|
export const CATEGORIES = ["movie", "music", "book"] as const;
|
|
2
4
|
export const MOODS = ["chill", "intense", "cozy", "spooky", "funny"] as const;
|
|
3
5
|
|
|
4
6
|
export type Category = (typeof CATEGORIES)[number];
|
|
5
7
|
export type Mood = (typeof MOODS)[number];
|
|
8
|
+
|
|
9
|
+
/** One answer from `recommend`, as the sidebar renders it. */
|
|
10
|
+
export type Rec = { category: Category; mood: Mood; picks: string[] };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The night's recommendation log — the agent's own state, not the page's.
|
|
14
|
+
*
|
|
15
|
+
* It used to live in a `useState` in `client.tsx`, rebuilt from a
|
|
16
|
+
* `ctx.send("recommendations", …)` event per call. That made the list a
|
|
17
|
+
* DERIVED thing: a page that mounted late, or reloaded mid-session, started
|
|
18
|
+
* empty while the session it reconnected to still remembered every pick. A
|
|
19
|
+
* slot is the same list stored once, on the side that already survives a
|
|
20
|
+
* reload.
|
|
21
|
+
*/
|
|
22
|
+
export const nightSlot = sessionSlot("night", () => ({ recs: [] as Rec[] }));
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What the browser sees. The projection BOTH ends use: `syncState` on the
|
|
26
|
+
* agent, `useAgentState` in the client — so the page's empty state is derived
|
|
27
|
+
* from the same function the server pushes, rather than guessed at.
|
|
28
|
+
*/
|
|
29
|
+
export const nightProjection = nightSlot.projection((night) => ({ recs: night.recs }));
|