ada-agent 0.3.1 → 0.4.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/README.md +4 -3
- package/docs/integrations.md +59 -5
- package/package.json +1 -1
- package/src/client/agent-server.ts +45 -0
- package/src/client/agent.ts +20 -6
- package/src/client/cli.ts +136 -5
- package/src/sdk/index.ts +111 -3
- package/src/selfcheck.ts +30 -1
package/README.md
CHANGED
|
@@ -158,9 +158,10 @@ shows in the prompt line. In **ask** mode each gated tool prompts with what it w
|
|
|
158
158
|
**auto** runs tools without asking (destructive `bash` still confirms). `--yolo` starts in **auto**.
|
|
159
159
|
|
|
160
160
|
**Subcommands:** `ada mcp …` (connectors) · `ada skill add <url>` · `ada worktree add <name>` ·
|
|
161
|
-
`ada catalog [provider]` (offline model/price catalog) · `ada serve` (HTTP API
|
|
162
|
-
|
|
163
|
-
[docs/integrations.md](docs/integrations.md) for the HTTP API
|
|
161
|
+
`ada catalog [provider]` (offline model/price catalog) · `ada serve` (HTTP API — one-shot **and**
|
|
162
|
+
Cursor-style streaming sessions for building ada into your own IDE) · `ada share` (view a session) ·
|
|
163
|
+
`ada acp` (editor bridge). See [docs/integrations.md](docs/integrations.md) for the HTTP API
|
|
164
|
+
(including live SSE sessions with approval gating), the typed SDK, and ACP.
|
|
164
165
|
|
|
165
166
|
**Orchestration strategies** — the harness runs pluggable agent architectures (`--strategy <name>`
|
|
166
167
|
or `/strategy`): `react` (default loop), `single` (one shot), `plan` (plan→execute), `multi`
|
package/docs/integrations.md
CHANGED
|
@@ -9,20 +9,74 @@ build, an IdP) are described with what they'd take — they can't be "live" with
|
|
|
9
9
|
```bash
|
|
10
10
|
ada serve # → http://localhost:8788 (ADA_HTTP_PORT to change)
|
|
11
11
|
```
|
|
12
|
-
- `GET /health` → `{ ok, model }`
|
|
13
|
-
- `POST /v1/prompt` `{ "text": "...", "model"?: "..." }` → `{ text, usage }`
|
|
12
|
+
- `GET /health` → `{ ok, model, sessions }`
|
|
13
|
+
- `POST /v1/prompt` `{ "text": "...", "model"?: "..." }` → `{ text, usage }` — **one-shot**: a fresh
|
|
14
|
+
agent + fresh session per call, no memory between calls. Good for a "generate this" button, not a
|
|
15
|
+
chat panel.
|
|
16
|
+
|
|
17
|
+
### Building a Cursor-style agent panel (an IDE integration)
|
|
18
|
+
|
|
19
|
+
For a real agent panel — persistent conversation, live streamed output, visible tool calls, and
|
|
20
|
+
edits that pause for **your own** approval UI instead of auto-running — use the **interactive
|
|
21
|
+
session** endpoints instead. This is the intended integration point for a custom IDE/editor, in any
|
|
22
|
+
language, over plain HTTP + Server-Sent Events:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
GET /v1/sessions → { sessions: [{ file, title, mtime, parent? }, …] }
|
|
26
|
+
POST /v1/sessions {"resume"?: "latest"|"<file>"} → { sessionId, model, file, resumed }
|
|
27
|
+
POST /v1/sessions/:id/prompt {"text":…} → SSE stream of events (see below), until "done"
|
|
28
|
+
POST /v1/sessions/:id/approve {"id":…, "decision":"yes"|"all"|"no"}
|
|
29
|
+
DELETE /v1/sessions/:id → free the session (does not delete the transcript)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The session holds one persistent `Agent` — history, model, and skill/tool state carry across every
|
|
33
|
+
`/prompt` call. Each `/prompt` call streams one event per SSE frame (`data: {...}\n\n`):
|
|
34
|
+
|
|
35
|
+
| `type` | Fields | Meaning |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `text` | `delta` | A chunk of the assistant's reply |
|
|
38
|
+
| `tool_call` | `name`, `detail` | A tool is about to run |
|
|
39
|
+
| `tool_result` | `name`, `output`, `isError` | It finished |
|
|
40
|
+
| `approval_request` | `id`, `name`, `summary` | **Blocks** until you POST `.../approve` with this `id` — this is where your IDE shows its own "allow this edit?" UI |
|
|
41
|
+
| `done` | `text`, `usage` | Turn complete |
|
|
42
|
+
| `error` | `message` | The turn failed (e.g. upstream unreachable) |
|
|
43
|
+
|
|
44
|
+
Sessions default to `autoApprove: false` (unlike the one-shot `/v1/prompt`, which auto-approves
|
|
45
|
+
everything) — every gated tool call (file writes, destructive shell, …) fires `approval_request` and
|
|
46
|
+
waits for your response. If no `/prompt` stream is currently open when an approval is needed, it's
|
|
47
|
+
declined (fails closed, never runs silently).
|
|
48
|
+
|
|
49
|
+
**Resuming after a restart.** Sessions live in memory, so a `sessionId` doesn't survive `ada serve`
|
|
50
|
+
restarting — but every session's conversation is also persisted to an on-disk transcript
|
|
51
|
+
(`.ada/sessions/*.jsonl`), same as the CLI's own sessions. `GET /v1/sessions` lists them (newest
|
|
52
|
+
first); pass `resume: "latest"` or a specific `file` from that list to `POST /v1/sessions` to spin up
|
|
53
|
+
a **new** in-memory session seeded with that history — the conversation picks up right where it left
|
|
54
|
+
off. Verified live: kill `ada serve` mid-conversation, restart it, resume, and the model still recalls
|
|
55
|
+
what was said before the restart.
|
|
14
56
|
|
|
15
57
|
## Typed SDK — `src/sdk`
|
|
16
58
|
|
|
17
59
|
```ts
|
|
18
60
|
import { createClient } from "ada-agent/sdk"; // in-repo: "./src/sdk/index.ts"
|
|
19
61
|
const ada = createClient("http://localhost:8788");
|
|
20
|
-
|
|
62
|
+
|
|
63
|
+
// one-shot
|
|
21
64
|
const { text } = await ada.prompt("list the files in this project");
|
|
65
|
+
|
|
66
|
+
// interactive — the IDE integration point
|
|
67
|
+
const session = await ada.session({ model: "claude-opus-4-8" });
|
|
68
|
+
await session.prompt("refactor foo.ts to use async/await", (e) => {
|
|
69
|
+
if (e.type === "text") process.stdout.write(e.delta);
|
|
70
|
+
if (e.type === "tool_call") console.log(`→ ${e.name} ${e.detail}`);
|
|
71
|
+
if (e.type === "approval_request") session.approve(e.id, myOwnConfirmUi(e) ? "yes" : "no");
|
|
72
|
+
if (e.type === "done") console.log("\n" + e.usage);
|
|
73
|
+
});
|
|
74
|
+
await session.close();
|
|
22
75
|
```
|
|
23
76
|
|
|
24
|
-
It's a
|
|
25
|
-
|
|
77
|
+
It's a `fetch`-based wrapper (manual SSE parsing, no dependency) over the HTTP API above — if you'd
|
|
78
|
+
rather not pull in the source, or your IDE isn't Node/TypeScript, talk to the same endpoints directly
|
|
79
|
+
from any HTTP client that can read a chunked response (Java, Python, Rust, a browser, …).
|
|
26
80
|
|
|
27
81
|
## ACP bridge — `ada acp`
|
|
28
82
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Pure helpers for ada's HTTP+SSE agent service (the session endpoints on `ada serve`). Kept
|
|
2
|
+
// separate from cli.ts's route wiring so the tricky bits — SSE framing, id generation, and
|
|
3
|
+
// approval correlation — are unit-testable offline, with no live model required.
|
|
4
|
+
|
|
5
|
+
import type { ApprovalDecision } from "./agent.ts";
|
|
6
|
+
|
|
7
|
+
/** Format one Server-Sent Events frame for a JSON-serializable event. */
|
|
8
|
+
export function sseFrame(event: unknown): string {
|
|
9
|
+
return `data: ${JSON.stringify(event)}\n\n`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let seq = 0;
|
|
13
|
+
/** A short, process-unique id (session id, approval-request id, …). */
|
|
14
|
+
export function newId(prefix: string): string {
|
|
15
|
+
seq++;
|
|
16
|
+
return `${prefix}_${Date.now().toString(36)}${seq.toString(36)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Correlates a mid-turn approval request with the IDE's later response. `wait()` is called from
|
|
21
|
+
* inside the Agent's onApprove callback (which is blocked on the returned promise); `settle()` is
|
|
22
|
+
* called from the POST .../approve handler once the IDE's user has decided.
|
|
23
|
+
*/
|
|
24
|
+
export class ApprovalRegistry {
|
|
25
|
+
private pending = new Map<string, (d: ApprovalDecision) => void>();
|
|
26
|
+
|
|
27
|
+
wait(): { id: string; promise: Promise<ApprovalDecision> } {
|
|
28
|
+
const id = newId("appr");
|
|
29
|
+
const promise = new Promise<ApprovalDecision>((resolve) => this.pending.set(id, resolve));
|
|
30
|
+
return { id, promise };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Resolve a pending approval by id. False if the id is unknown (already settled, or bogus). */
|
|
34
|
+
settle(id: string, decision: ApprovalDecision): boolean {
|
|
35
|
+
const resolve = this.pending.get(id);
|
|
36
|
+
if (!resolve) return false;
|
|
37
|
+
this.pending.delete(id);
|
|
38
|
+
resolve(decision);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get size(): number {
|
|
43
|
+
return this.pending.size;
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/client/agent.ts
CHANGED
|
@@ -15,7 +15,14 @@ import { routeConfident, routeSkills } from "./skills.ts";
|
|
|
15
15
|
import { Session } from "./session.ts";
|
|
16
16
|
|
|
17
17
|
type Msg = OpenAI.Chat.Completions.ChatCompletionMessageParam;
|
|
18
|
-
|
|
18
|
+
/** Structured turn events — for a caller (e.g. an IDE service) that wants more than plain text.
|
|
19
|
+
* When `onEvent` is set on SendCtrl, `send()` emits these instead of writing to stdout. */
|
|
20
|
+
export type AgentEvent =
|
|
21
|
+
| { type: "text"; delta: string }
|
|
22
|
+
| { type: "tool_call"; name: string; detail: string }
|
|
23
|
+
| { type: "tool_result"; name: string; output: string; isError: boolean }
|
|
24
|
+
| { type: "done"; text: string; usage: string };
|
|
25
|
+
type SendCtrl = { signal?: AbortSignal; steer?: string[]; quiet?: boolean; images?: string[]; onReplyStart?: () => void; onEvent?: (e: AgentEvent) => void };
|
|
19
26
|
type ToolCall = { id: string; name: string; args: string };
|
|
20
27
|
type StepResult = { content: string; toolCalls: ToolCall[] };
|
|
21
28
|
type ToolDef = OpenAI.Chat.Completions.ChatCompletionTool;
|
|
@@ -493,6 +500,10 @@ export class Agent {
|
|
|
493
500
|
async send(input: string, ctrl?: SendCtrl): Promise<string> {
|
|
494
501
|
let replyStarted = false;
|
|
495
502
|
const say = (s: string): void => {
|
|
503
|
+
if (ctrl?.onEvent) {
|
|
504
|
+
if (s.trim()) ctrl.onEvent({ type: "text", delta: s });
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
496
507
|
if (ctrl?.quiet) return;
|
|
497
508
|
if (!replyStarted && s.trim()) {
|
|
498
509
|
replyStarted = true;
|
|
@@ -539,6 +550,7 @@ export class Agent {
|
|
|
539
550
|
|
|
540
551
|
const engine = this.makeEngine(ctrl, say, interrupted, drainSteer);
|
|
541
552
|
await (ORCHESTRATORS[this.strategy] ?? reAct).run(engine);
|
|
553
|
+
ctrl?.onEvent?.({ type: "done", text: this.lastAssistant, usage: this.usageReport() });
|
|
542
554
|
return this.lastAssistant;
|
|
543
555
|
}
|
|
544
556
|
|
|
@@ -690,9 +702,11 @@ export class Agent {
|
|
|
690
702
|
const printCall = (name: string, args: Record<string, unknown>): void => {
|
|
691
703
|
const d = describeCall(name, args);
|
|
692
704
|
const detail = d.detail ? ` ${d.detail.length > 100 ? `${d.detail.slice(0, 99)}…` : d.detail}` : "";
|
|
705
|
+
ctrl?.onEvent?.({ type: "tool_call", name, detail: d.detail });
|
|
693
706
|
say(`\n\x1b[2m• ${name}${detail}\x1b[0m\n`);
|
|
694
707
|
};
|
|
695
|
-
const printResult = (r: ToolResult): void => {
|
|
708
|
+
const printResult = (name: string, r: ToolResult): void => {
|
|
709
|
+
ctrl?.onEvent?.({ type: "tool_result", name, output: r.output, isError: !!r.isError });
|
|
696
710
|
if (r.display) say(`${r.display}\n`);
|
|
697
711
|
else if (r.isError) say(`\x1b[31m ${r.output.split("\n")[0]}\x1b[0m\n`);
|
|
698
712
|
};
|
|
@@ -728,7 +742,7 @@ export class Agent {
|
|
|
728
742
|
if (perm === "deny") {
|
|
729
743
|
printCall(c.name, args);
|
|
730
744
|
results[i] = { output: "Denied by permission policy.", isError: true };
|
|
731
|
-
printResult(results[i]!);
|
|
745
|
+
printResult(c.name, results[i]!);
|
|
732
746
|
continue;
|
|
733
747
|
}
|
|
734
748
|
if (!tool.needsApproval && perm !== "ask") {
|
|
@@ -739,7 +753,7 @@ export class Agent {
|
|
|
739
753
|
printCall(c.name, args);
|
|
740
754
|
if (this.planMode && tool.needsApproval) {
|
|
741
755
|
results[i] = { output: "Plan mode: not executing — finish the plan; the user approves with /run." };
|
|
742
|
-
printResult(results[i]!);
|
|
756
|
+
printResult(c.name, results[i]!);
|
|
743
757
|
continue;
|
|
744
758
|
}
|
|
745
759
|
const forceConfirm = c.name === "bash" && isDestructive(String(args.command ?? ""));
|
|
@@ -757,7 +771,7 @@ export class Agent {
|
|
|
757
771
|
results[i] = await runTool(tool, c.name, args);
|
|
758
772
|
}
|
|
759
773
|
}
|
|
760
|
-
printResult(results[i]!);
|
|
774
|
+
printResult(c.name, results[i]!);
|
|
761
775
|
}
|
|
762
776
|
await Promise.all(
|
|
763
777
|
parallel.map(async (i) => {
|
|
@@ -765,7 +779,7 @@ export class Agent {
|
|
|
765
779
|
const args = argsOf(c.args);
|
|
766
780
|
printCall(c.name, args);
|
|
767
781
|
results[i] = await runTool(toolByName.get(c.name)!, c.name, args);
|
|
768
|
-
printResult(results[i]!);
|
|
782
|
+
printResult(c.name, results[i]!);
|
|
769
783
|
}),
|
|
770
784
|
);
|
|
771
785
|
for (let i = 0; i < toolCalls.length; i++) {
|
package/src/client/cli.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { readFileSync } from "node:fs";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { stdin, stdout } from "node:process";
|
|
9
9
|
import OpenAI from "openai";
|
|
10
|
-
import { Agent, type ApprovalDecision, type OnApprove } from "./agent.ts";
|
|
10
|
+
import { Agent, type AgentEvent, type ApprovalDecision, type OnApprove } from "./agent.ts";
|
|
11
|
+
import { ApprovalRegistry, newId, sseFrame } from "./agent-server.ts";
|
|
11
12
|
import { expandPrompt, loadPrompts } from "./prompts.ts";
|
|
12
13
|
import { Session, list, type SessionMeta } from "./session.ts";
|
|
13
14
|
import { deleteCredential, getCredential, listCredentials } from "../server/credentials.ts";
|
|
@@ -727,13 +728,51 @@ async function main(): Promise<void> {
|
|
|
727
728
|
}
|
|
728
729
|
}
|
|
729
730
|
const port = Number(process.env.ADA_HTTP_PORT) || 8788;
|
|
731
|
+
|
|
732
|
+
// Interactive sessions — for driving ada like an IDE agent panel (live text/tool-call events,
|
|
733
|
+
// and edits pause for YOUR approval UI instead of auto-running). See docs/integrations.md.
|
|
734
|
+
interface AgentSession {
|
|
735
|
+
agent: Agent;
|
|
736
|
+
registry: ApprovalRegistry;
|
|
737
|
+
emit: ((frame: string) => void) | null; // set only while a /prompt request's SSE stream is open
|
|
738
|
+
file: string; // the on-disk transcript — survives an `ada serve` restart; resume with it
|
|
739
|
+
}
|
|
740
|
+
const sessions = new Map<string, AgentSession>();
|
|
741
|
+
// `resumeFile` reattaches to an existing on-disk transcript (e.g. after `ada serve` restarted) —
|
|
742
|
+
// its history replays into the new in-memory Agent so the conversation picks up where it left off.
|
|
743
|
+
const makeSession = (m: string, resumeFile?: string): { id: string; rec: AgentSession } => {
|
|
744
|
+
const session = resumeFile ? Session.open(resumeFile) : Session.create();
|
|
745
|
+
const history = resumeFile ? (session.load() as unknown as Msg[]) : undefined;
|
|
746
|
+
const rec: AgentSession = { agent: undefined as unknown as Agent, registry: new ApprovalRegistry(), emit: null, file: session.file };
|
|
747
|
+
rec.agent = new Agent({
|
|
748
|
+
client,
|
|
749
|
+
model: m,
|
|
750
|
+
session,
|
|
751
|
+
history,
|
|
752
|
+
project: trusted,
|
|
753
|
+
compactAt: settings.compactAt,
|
|
754
|
+
autoApprove: false,
|
|
755
|
+
onApprove: async (toolName, summary): Promise<ApprovalDecision> => {
|
|
756
|
+
if (!rec.emit) return "no"; // no open stream to ask through — fail closed, don't silently run
|
|
757
|
+
const { id, promise } = rec.registry.wait();
|
|
758
|
+
rec.emit(sseFrame({ type: "approval_request", id, name: toolName, summary }));
|
|
759
|
+
return promise;
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
const id = newId("sess");
|
|
763
|
+
sessions.set(id, rec);
|
|
764
|
+
return { id, rec };
|
|
765
|
+
};
|
|
766
|
+
|
|
730
767
|
const { createServer } = await import("node:http");
|
|
731
768
|
createServer((req, res) => {
|
|
732
|
-
|
|
733
|
-
|
|
769
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
770
|
+
if (req.method === "GET" && (url.pathname === "/health" || url.pathname === "/")) {
|
|
771
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, model, sessions: sessions.size }));
|
|
734
772
|
return;
|
|
735
773
|
}
|
|
736
|
-
|
|
774
|
+
// One-shot, no memory between calls — good for a "generate this" action, not a chat panel.
|
|
775
|
+
if (req.method === "POST" && url.pathname === "/v1/prompt") {
|
|
737
776
|
let body = "";
|
|
738
777
|
req.on("data", (c) => (body += c));
|
|
739
778
|
req.on("end", async () => {
|
|
@@ -748,8 +787,100 @@ async function main(): Promise<void> {
|
|
|
748
787
|
});
|
|
749
788
|
return;
|
|
750
789
|
}
|
|
790
|
+
// Interactive: persistent session, streamed events, approval round-trip.
|
|
791
|
+
// List on-disk transcripts (survive an `ada serve` restart) so an IDE can offer "resume".
|
|
792
|
+
if (req.method === "GET" && url.pathname === "/v1/sessions") {
|
|
793
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ sessions: list() }));
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
if (req.method === "POST" && url.pathname === "/v1/sessions") {
|
|
797
|
+
let body = "";
|
|
798
|
+
req.on("data", (c) => (body += c));
|
|
799
|
+
req.on("end", () => {
|
|
800
|
+
let m = model;
|
|
801
|
+
let resume: string | undefined;
|
|
802
|
+
try {
|
|
803
|
+
const j = JSON.parse(body || "{}") as { model?: string; resume?: string };
|
|
804
|
+
m = j.model || model;
|
|
805
|
+
// "latest" picks the most recently modified transcript; otherwise resume expects one of
|
|
806
|
+
// the `file` values from GET /v1/sessions (a restarted `ada serve` has no memory of which
|
|
807
|
+
// in-memory sessionIds existed before, so the IDE re-resolves by transcript file instead).
|
|
808
|
+
if (j.resume === "latest") resume = list()[0]?.file;
|
|
809
|
+
else if (j.resume && list().some((s) => s.file === j.resume)) resume = j.resume;
|
|
810
|
+
} catch {
|
|
811
|
+
/* ignore, use default model + no resume */
|
|
812
|
+
}
|
|
813
|
+
const { id, rec } = makeSession(m, resume);
|
|
814
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ sessionId: id, model: m, file: rec.file, resumed: !!resume }));
|
|
815
|
+
});
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const promptMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/prompt$/);
|
|
819
|
+
if (promptMatch) {
|
|
820
|
+
const rec = sessions.get(promptMatch[1]!);
|
|
821
|
+
if (!rec) {
|
|
822
|
+
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
let body = "";
|
|
826
|
+
req.on("data", (c) => (body += c));
|
|
827
|
+
req.on("end", async () => {
|
|
828
|
+
let text = "";
|
|
829
|
+
try {
|
|
830
|
+
text = String((JSON.parse(body || "{}") as { text?: string }).text ?? "");
|
|
831
|
+
} catch {
|
|
832
|
+
/* empty prompt */
|
|
833
|
+
}
|
|
834
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
835
|
+
rec.emit = (frame) => res.write(frame);
|
|
836
|
+
try {
|
|
837
|
+
await rec.agent.send(text, { onEvent: (e: AgentEvent) => res.write(sseFrame(e)) });
|
|
838
|
+
} catch (e) {
|
|
839
|
+
res.write(sseFrame({ type: "error", message: e instanceof Error ? e.message : String(e) }));
|
|
840
|
+
} finally {
|
|
841
|
+
rec.emit = null;
|
|
842
|
+
res.end();
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
const approveMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/approve$/);
|
|
848
|
+
if (approveMatch) {
|
|
849
|
+
const rec = sessions.get(approveMatch[1]!);
|
|
850
|
+
if (!rec) {
|
|
851
|
+
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
let body = "";
|
|
855
|
+
req.on("data", (c) => (body += c));
|
|
856
|
+
req.on("end", () => {
|
|
857
|
+
let ok = false;
|
|
858
|
+
try {
|
|
859
|
+
const { id, decision } = JSON.parse(body || "{}") as { id?: string; decision?: ApprovalDecision };
|
|
860
|
+
if (id && decision) ok = rec.registry.settle(id, decision);
|
|
861
|
+
} catch {
|
|
862
|
+
/* ok stays false */
|
|
863
|
+
}
|
|
864
|
+
res.writeHead(ok ? 200 : 404, { "content-type": "application/json" }).end(JSON.stringify({ ok }));
|
|
865
|
+
});
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const delMatch = req.method === "DELETE" && url.pathname.match(/^\/v1\/sessions\/([^/]+)$/);
|
|
869
|
+
if (delMatch) {
|
|
870
|
+
const existed = sessions.delete(delMatch[1]!);
|
|
871
|
+
res.writeHead(existed ? 200 : 404, { "content-type": "application/json" }).end(JSON.stringify({ ok: existed }));
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
751
874
|
res.writeHead(404).end();
|
|
752
|
-
}).listen(port, () =>
|
|
875
|
+
}).listen(port, () =>
|
|
876
|
+
console.log(
|
|
877
|
+
`ada HTTP API on http://localhost:${port} · model ${model || "(none — set one)"}\n` +
|
|
878
|
+
` one-shot: POST /v1/prompt {"text":"…"}\n` +
|
|
879
|
+
` interactive: POST /v1/sessions → {sessionId}\n` +
|
|
880
|
+
` POST /v1/sessions/:id/prompt {"text":"…"} (SSE: text/tool_call/tool_result/approval_request/done)\n` +
|
|
881
|
+
` POST /v1/sessions/:id/approve {"id":"…","decision":"yes"|"all"|"no"}`,
|
|
882
|
+
),
|
|
883
|
+
);
|
|
753
884
|
await new Promise(() => {}); // keep the process alive for the server
|
|
754
885
|
return;
|
|
755
886
|
}
|
package/src/sdk/index.ts
CHANGED
|
@@ -1,21 +1,91 @@
|
|
|
1
|
-
// Typed client SDK for the ada HTTP API (started with `ada serve`).
|
|
1
|
+
// Typed client SDK for the ada HTTP API (started with `ada serve`). Two ways to drive ada:
|
|
2
2
|
//
|
|
3
|
-
// import { createClient } from "ada/sdk";
|
|
3
|
+
// import { createClient } from "ada-agent/sdk"; // or "./src/sdk/index.ts" in-repo
|
|
4
4
|
// const ada = createClient("http://localhost:8788");
|
|
5
|
+
//
|
|
6
|
+
// One-shot (no memory between calls — a "generate this" action, not a chat panel):
|
|
5
7
|
// const { text } = await ada.prompt("list the files in this project");
|
|
8
|
+
//
|
|
9
|
+
// Interactive (a Cursor-style agent panel — persistent session, live events, edits pause for your
|
|
10
|
+
// own approval UI instead of auto-running):
|
|
11
|
+
// const session = await ada.session();
|
|
12
|
+
// await session.prompt("refactor foo.ts", (e) => {
|
|
13
|
+
// if (e.type === "text") process.stdout.write(e.delta);
|
|
14
|
+
// if (e.type === "tool_call") console.log(`→ ${e.name} ${e.detail}`);
|
|
15
|
+
// if (e.type === "approval_request") session.approve(e.id, myOwnConfirmUi(e.name, e.summary) ? "yes" : "no");
|
|
16
|
+
// });
|
|
6
17
|
|
|
7
18
|
export interface PromptResult {
|
|
8
19
|
text: string;
|
|
9
20
|
usage?: string;
|
|
10
21
|
}
|
|
11
22
|
|
|
23
|
+
/** One event from an interactive session's prompt stream. */
|
|
24
|
+
export type SessionEvent =
|
|
25
|
+
| { type: "text"; delta: string }
|
|
26
|
+
| { type: "tool_call"; name: string; detail: string }
|
|
27
|
+
| { type: "tool_result"; name: string; output: string; isError: boolean }
|
|
28
|
+
| { type: "approval_request"; id: string; name: string; summary: string }
|
|
29
|
+
| { type: "done"; text: string; usage: string }
|
|
30
|
+
| { type: "error"; message: string };
|
|
31
|
+
|
|
32
|
+
export interface AdaSession {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
/** The on-disk transcript backing this session — survives an `ada serve` restart. Pass this (or
|
|
35
|
+
* `"latest"`) as `resume` to a later `session()` call to reattach after one. */
|
|
36
|
+
readonly file: string;
|
|
37
|
+
/** True if this session's history was seeded from an existing transcript. */
|
|
38
|
+
readonly resumed: boolean;
|
|
39
|
+
/** Send a prompt; `onEvent` fires for every event as the turn streams. Resolves once it's done. */
|
|
40
|
+
prompt(text: string, onEvent: (e: SessionEvent) => void): Promise<void>;
|
|
41
|
+
/** Answer a pending `approval_request` event by its id. */
|
|
42
|
+
approve(id: string, decision: "yes" | "all" | "no"): Promise<void>;
|
|
43
|
+
/** Free the session's resources server-side. (Does not delete the on-disk transcript.) */
|
|
44
|
+
close(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One on-disk session transcript, as returned by `listSessions()`. */
|
|
48
|
+
export interface SessionMeta {
|
|
49
|
+
file: string;
|
|
50
|
+
mtime: number;
|
|
51
|
+
title: string;
|
|
52
|
+
parent?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
12
55
|
export interface AdaClient {
|
|
13
|
-
/**
|
|
56
|
+
/** One-shot: runs a fresh agent turn server-side (no memory between calls) and returns its final text. */
|
|
14
57
|
prompt(text: string, opts?: { model?: string }): Promise<PromptResult>;
|
|
58
|
+
/**
|
|
59
|
+
* Start a persistent, streaming session — the Cursor-style integration point for an IDE.
|
|
60
|
+
* Pass `resume: "latest"` or a `file` from `listSessions()` to reattach an existing conversation
|
|
61
|
+
* (e.g. after `ada serve` restarted and the old in-memory sessionId is gone).
|
|
62
|
+
*/
|
|
63
|
+
session(opts?: { model?: string; resume?: string }): Promise<AdaSession>;
|
|
64
|
+
/** On-disk session transcripts, newest first — for building a "resume which conversation?" picker. */
|
|
65
|
+
listSessions(): Promise<SessionMeta[]>;
|
|
15
66
|
/** Server health + the default model. */
|
|
16
67
|
health(): Promise<{ ok: boolean; model?: string }>;
|
|
17
68
|
}
|
|
18
69
|
|
|
70
|
+
async function streamSse(res: Response, onEvent: (e: SessionEvent) => void): Promise<void> {
|
|
71
|
+
if (!res.ok || !res.body) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
|
|
72
|
+
const reader = res.body.getReader();
|
|
73
|
+
const decoder = new TextDecoder();
|
|
74
|
+
let buf = "";
|
|
75
|
+
for (;;) {
|
|
76
|
+
const { done, value } = await reader.read();
|
|
77
|
+
if (done) break;
|
|
78
|
+
buf += decoder.decode(value, { stream: true });
|
|
79
|
+
let idx: number;
|
|
80
|
+
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
81
|
+
const frame = buf.slice(0, idx);
|
|
82
|
+
buf = buf.slice(idx + 2);
|
|
83
|
+
const line = frame.split("\n").find((l) => l.startsWith("data: "));
|
|
84
|
+
if (line) onEvent(JSON.parse(line.slice(6)) as SessionEvent);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
19
89
|
export function createClient(baseUrl = "http://localhost:8788"): AdaClient {
|
|
20
90
|
const url = baseUrl.replace(/\/+$/, "");
|
|
21
91
|
return {
|
|
@@ -28,6 +98,44 @@ export function createClient(baseUrl = "http://localhost:8788"): AdaClient {
|
|
|
28
98
|
if (!res.ok) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
|
|
29
99
|
return (await res.json()) as PromptResult;
|
|
30
100
|
},
|
|
101
|
+
async session(opts) {
|
|
102
|
+
const res = await fetch(`${url}/v1/sessions`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "content-type": "application/json" },
|
|
105
|
+
body: JSON.stringify({ model: opts?.model, resume: opts?.resume }),
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
|
|
108
|
+
const { sessionId, file, resumed } = (await res.json()) as { sessionId: string; file: string; resumed: boolean };
|
|
109
|
+
return {
|
|
110
|
+
id: sessionId,
|
|
111
|
+
file,
|
|
112
|
+
resumed,
|
|
113
|
+
async prompt(text, onEvent) {
|
|
114
|
+
const r = await fetch(`${url}/v1/sessions/${sessionId}/prompt`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { "content-type": "application/json" },
|
|
117
|
+
body: JSON.stringify({ text }),
|
|
118
|
+
});
|
|
119
|
+
await streamSse(r, onEvent);
|
|
120
|
+
},
|
|
121
|
+
async approve(id, decision) {
|
|
122
|
+
const r = await fetch(`${url}/v1/sessions/${sessionId}/approve`, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: { "content-type": "application/json" },
|
|
125
|
+
body: JSON.stringify({ id, decision }),
|
|
126
|
+
});
|
|
127
|
+
if (!r.ok) throw new Error(`ada ${r.status}: could not settle approval ${id}`);
|
|
128
|
+
},
|
|
129
|
+
async close() {
|
|
130
|
+
await fetch(`${url}/v1/sessions/${sessionId}`, { method: "DELETE" });
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
async listSessions() {
|
|
135
|
+
const res = await fetch(`${url}/v1/sessions`);
|
|
136
|
+
if (!res.ok) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
|
|
137
|
+
return ((await res.json()) as { sessions: SessionMeta[] }).sessions;
|
|
138
|
+
},
|
|
31
139
|
async health() {
|
|
32
140
|
const res = await fetch(`${url}/health`);
|
|
33
141
|
return (await res.json()) as { ok: boolean; model?: string };
|
package/src/selfcheck.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { expandPrompt } from "./client/prompts.ts";
|
|
|
11
11
|
import { MarkdownStreamer, highlight, renderEditDiff } from "./client/render.ts";
|
|
12
12
|
import { Session, list } from "./client/session.ts";
|
|
13
13
|
import { loadSkills, registerSkillTool, routeConfident } from "./client/skills.ts";
|
|
14
|
-
import { describeCall, parseTextToolCalls, permPhrase, readIntegrationDocs, soleIntegration, writeProjectSkills } from "./client/agent.ts";
|
|
14
|
+
import { Agent, describeCall, parseTextToolCalls, permPhrase, readIntegrationDocs, soleIntegration, writeProjectSkills } from "./client/agent.ts";
|
|
15
15
|
import { userBar } from "./client/tui.ts";
|
|
16
16
|
import { configuredServers, listConnectors, loadMcpServers } from "./client/mcp.ts";
|
|
17
17
|
import { confidentSkill, rankSkills } from "./client/skill-router.ts";
|
|
@@ -117,6 +117,18 @@ async function main(): Promise<void> {
|
|
|
117
117
|
rmSync(parent.file, { force: true });
|
|
118
118
|
rmSync(branch.file, { force: true });
|
|
119
119
|
|
|
120
|
+
// --- resume: a session's on-disk history seeds a fresh Agent's context (no live model needed) ---
|
|
121
|
+
{
|
|
122
|
+
const s = Session.create();
|
|
123
|
+
s.append({ role: "user", content: "remember: the secret word is PINEAPPLE97" });
|
|
124
|
+
s.append({ role: "assistant", content: "got it" });
|
|
125
|
+
const history = s.load() as never[];
|
|
126
|
+
const bare = new Agent({ client: {} as never, model: "x", session: Session.create(), onApprove: async () => "yes" });
|
|
127
|
+
const resumed = new Agent({ client: {} as never, model: "x", session: s, onApprove: async () => "yes", history });
|
|
128
|
+
assert.ok(resumed.contextTokens() > bare.contextTokens(), "resuming with history seeds more context than a bare session");
|
|
129
|
+
rmSync(s.file, { force: true });
|
|
130
|
+
}
|
|
131
|
+
|
|
120
132
|
// --- router prefix mapping ---
|
|
121
133
|
assert.equal(route("gpt-4o"), "openai");
|
|
122
134
|
assert.equal(route("o3-mini"), "openai");
|
|
@@ -299,6 +311,23 @@ async function main(): Promise<void> {
|
|
|
299
311
|
const jid = startJob("selfcheck job", async () => "job-done-ok");
|
|
300
312
|
await new Promise((r) => setTimeout(r, 30));
|
|
301
313
|
assert.ok(renderJobs().includes(jid) && /job-done-ok/.test(renderJobs()), "background job runs and reports its result");
|
|
314
|
+
|
|
315
|
+
// --- agent-server helpers: SSE framing, id uniqueness, approval correlation (no live model needed) ---
|
|
316
|
+
{
|
|
317
|
+
const { sseFrame, newId, ApprovalRegistry } = await import("./client/agent-server.ts");
|
|
318
|
+
assert.equal(sseFrame({ type: "done", text: "hi" }), 'data: {"type":"done","text":"hi"}\n\n', "sseFrame formats one data: frame");
|
|
319
|
+
const a = newId("sess");
|
|
320
|
+
const b = newId("sess");
|
|
321
|
+
assert.ok(a.startsWith("sess_") && a !== b, "newId is prefixed and unique");
|
|
322
|
+
|
|
323
|
+
const registry = new ApprovalRegistry();
|
|
324
|
+
const { id, promise } = registry.wait();
|
|
325
|
+
assert.equal(registry.size, 1, "wait() tracks one pending approval");
|
|
326
|
+
assert.ok(registry.settle(id, "yes"), "settle() resolves a known pending approval");
|
|
327
|
+
assert.equal(await promise, "yes", "the waiting promise resolves with the decision");
|
|
328
|
+
assert.equal(registry.size, 0, "settle() clears the pending entry");
|
|
329
|
+
assert.equal(registry.settle("nope", "no"), false, "settle() on an unknown id returns false");
|
|
330
|
+
}
|
|
302
331
|
assert.equal((await toolByName.get("web_fetch")!.run({ url: "http://127.0.0.1/x" })).isError, true, "web_fetch blocks loopback (SSRF guard)");
|
|
303
332
|
|
|
304
333
|
// --- destructive classifier: real dangers flagged; everyday redirects are not (2>/dev/null bug) ---
|