glove-foundry 0.5.1 → 0.5.2
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 +27 -15
- package/dist/{chunk-KBMD4RYU.js → chunk-6Q2LGPSC.js} +49 -198
- package/dist/{chunk-GKZLWD5M.js → chunk-7F7QVO6I.js} +4 -1
- package/dist/{chunk-SGF3CFUR.js → chunk-D2AHSYOB.js} +71 -8
- package/dist/{chunk-TH5ETCG2.js → chunk-MABLKZVO.js} +13 -4
- package/dist/{chunk-LW5PJ2FN.js → chunk-NHV64WNO.js} +1 -1
- package/dist/{chunk-5NFLGHOC.js → chunk-PVOKR4CN.js} +211 -29
- package/dist/cli.js +7 -5
- package/dist/{client-DcsnudMI.d.ts → client-CLTds1uV.d.ts} +13 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/execution-agent.js +4 -4
- package/dist/execution-daemon.js +1 -1
- package/dist/execution-loader.js +4 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +14 -14
- package/docs/building-with-foundry.md +4 -2
- package/docs/release-verification.md +1 -1
- package/package.json +6 -6
- package/templates/minimal/README.md +34 -8
- package/templates/minimal/agents/assistant/agent.ts +24 -10
- package/templates/minimal/foundry.application.ts +11 -3
- package/templates/minimal/gitignore +1 -0
- package/templates/minimal/lib/file-store.ts +95 -0
- package/templates/travel-concierge/README.md +46 -12
- package/templates/travel-concierge/agents/concierge/agent.ts +10 -4
- package/templates/travel-concierge/agents/concierge/composition.ts +9 -2
- package/templates/travel-concierge/agents/concierge/flights.ts +43 -0
- package/templates/travel-concierge/agents/concierge/instances.ts +42 -0
- package/templates/travel-concierge/agents/concierge/memory/traveller.memory.ts +13 -8
- package/templates/travel-concierge/agents/concierge/tools/convert-currency.tool.ts +34 -0
- package/templates/travel-concierge/agents/concierge/workbench.ts +4 -1
- package/templates/travel-concierge/foundry.application.ts +15 -6
- package/templates/travel-concierge/gitignore +1 -0
- package/templates/travel-concierge/lib/demo-model.ts +89 -25
- package/templates/travel-concierge/lib/file-context.ts +60 -0
- package/templates/travel-concierge/lib/file-store.ts +95 -0
- package/templates/minimal/agents/assistant/tools/current-time.tool.ts +0 -17
- package/templates/travel-concierge/agents/concierge/tools/find-flights.tool.ts +0 -49
|
@@ -3,15 +3,22 @@ import calendar from "./apps/calendar.app.js";
|
|
|
3
3
|
import tripContext from "./layers/trip-context.layer.js";
|
|
4
4
|
import travellerMemory from "./memory/traveller.memory.js";
|
|
5
5
|
import usage from "./subscribers/usage.subscriber.js";
|
|
6
|
-
import
|
|
6
|
+
import convertCurrency from "./tools/convert-currency.tool.js";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
+
* The catalogue: everything this agent's instances are *allowed* to use.
|
|
10
|
+
*
|
|
11
|
+
* - `calendar` and `convertCurrency` are installable. They mount only on an
|
|
12
|
+
* instance that installs them (see instances.ts).
|
|
13
|
+
* - `travellerMemory`, `tripContext` and `usage` are definition surfaces; they
|
|
14
|
+
* are also selected in agent.ts (`memory`, `layers`, `subscribers`).
|
|
15
|
+
*
|
|
9
16
|
* Composition is by value, not by string id. Rename a file and the import
|
|
10
17
|
* breaks at compile time instead of failing at runtime.
|
|
11
18
|
*/
|
|
12
19
|
export const conciergeComponents = composeAgent(
|
|
13
20
|
calendar,
|
|
14
|
-
|
|
21
|
+
convertCurrency,
|
|
15
22
|
travellerMemory,
|
|
16
23
|
tripContext,
|
|
17
24
|
usage,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Searching flights is the concierge's core job, so the tool lives in the
|
|
5
|
+
* agent's `tools` field: every instance has it, nothing to install. Compare
|
|
6
|
+
* `tools/convert-currency.tool.ts`, which an instance opts into.
|
|
7
|
+
*
|
|
8
|
+
* The sample data is deliberately local so a fresh project runs offline.
|
|
9
|
+
* Replace `search` with a call to your own inventory or a provider API.
|
|
10
|
+
*/
|
|
11
|
+
const SAMPLE_FLIGHTS = [
|
|
12
|
+
{ carrier: "Kestrel Air", from: "LIS", to: "NBO", depart: "08:40", hours: 9.5, price: 612 },
|
|
13
|
+
{ carrier: "Meridian", from: "LIS", to: "NBO", depart: "13:15", hours: 11, price: 494 },
|
|
14
|
+
{ carrier: "Northwind", from: "LIS", to: "NBO", depart: "22:05", hours: 9, price: 738 },
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
function search(from: string, to: string, maxPrice: number) {
|
|
18
|
+
const origin = from.trim().toUpperCase();
|
|
19
|
+
const destination = to.trim().toUpperCase();
|
|
20
|
+
return SAMPLE_FLIGHTS.filter((flight) =>
|
|
21
|
+
(!origin || flight.from === origin) &&
|
|
22
|
+
(!destination || flight.to === destination) &&
|
|
23
|
+
flight.price <= maxPrice
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const findFlights = {
|
|
28
|
+
name: "find_flights",
|
|
29
|
+
description:
|
|
30
|
+
"Search flights between two IATA airport codes. Returns carrier, departure time, duration and price in USD.",
|
|
31
|
+
inputSchema: z.object({
|
|
32
|
+
from: z.string().describe("Origin IATA code, for example LIS"),
|
|
33
|
+
to: z.string().describe("Destination IATA code, for example NBO"),
|
|
34
|
+
maxPrice: z.number().default(5_000).describe("Highest acceptable price in USD"),
|
|
35
|
+
}),
|
|
36
|
+
async do({ from, to, maxPrice }: { from: string; to: string; maxPrice: number }) {
|
|
37
|
+
const matches = search(from, to, maxPrice);
|
|
38
|
+
if (matches.length === 0) {
|
|
39
|
+
return { status: "success" as const, data: { matches: [], note: "No flight matched those constraints." } };
|
|
40
|
+
}
|
|
41
|
+
return { status: "success" as const, data: { matches } };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { defineAgentInstance, defineBinding, install } from "glove-foundry";
|
|
2
|
+
import concierge from "./agent.js";
|
|
3
|
+
import calendar from "./apps/calendar.app.js";
|
|
4
|
+
import convertCurrency from "./tools/convert-currency.tool.js";
|
|
5
|
+
import { travellerAccount, travellerOutbound } from "./topology.js";
|
|
6
|
+
import messaging, { sendMessage } from "./transmissions/messaging.transmission.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A ready-made instance, seeded into the data adapter on first start
|
|
10
|
+
* (foundry.application.ts). It has what a new instance created from the
|
|
11
|
+
* inspector does not: installed capabilities and permission to reply.
|
|
12
|
+
*
|
|
13
|
+
* Choose `concierge-demo` in the inspector's run drawer or WebChat to use it.
|
|
14
|
+
*/
|
|
15
|
+
export const conciergeDemo = defineAgentInstance(concierge, {
|
|
16
|
+
id: "concierge-demo",
|
|
17
|
+
workspaceId: "demo",
|
|
18
|
+
context: { budgetUsd: 2_500 },
|
|
19
|
+
installations: [
|
|
20
|
+
install(convertCurrency, { home: "EUR" }),
|
|
21
|
+
install(calendar, { calendarId: "primary", timezone: "Europe/Lisbon" }, { account: travellerAccount }),
|
|
22
|
+
],
|
|
23
|
+
// Filled in by agent.ts's `playbooks` resolver on this instance's first run.
|
|
24
|
+
playbooks: [],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Outbound messages need a binding: the runtime's record that this instance
|
|
29
|
+
* may send through this route. Without one, a reply fails with
|
|
30
|
+
* "agent has no enabled bindings".
|
|
31
|
+
*/
|
|
32
|
+
export const conciergeDemoReplies = defineBinding({
|
|
33
|
+
id: "concierge-demo-replies",
|
|
34
|
+
agent: conciergeDemo,
|
|
35
|
+
application: calendar,
|
|
36
|
+
transmission: messaging,
|
|
37
|
+
account: travellerAccount,
|
|
38
|
+
route: travellerOutbound,
|
|
39
|
+
capabilities: [sendMessage],
|
|
40
|
+
reply: { mode: "route", route: travellerOutbound },
|
|
41
|
+
enabled: true,
|
|
42
|
+
});
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
import { defineMemory } from "glove-foundry";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { FileContextAdapter } from "../../../lib/file-context.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Ambient context the concierge
|
|
8
|
-
* preference, home airport, who is travelling.
|
|
9
|
-
*
|
|
7
|
+
* Ambient context the concierge carries between conversations: seat
|
|
8
|
+
* preference, home airport, who is travelling. One file per agent instance, so
|
|
9
|
+
* two travellers never share a profile. The model reads it every turn and can
|
|
10
|
+
* update it with the context tools.
|
|
10
11
|
*/
|
|
11
|
-
const
|
|
12
|
-
const context = new InMemoryContextAdapter({ schema });
|
|
12
|
+
const directory = new URL("../../../.data/memory/", import.meta.url);
|
|
13
13
|
|
|
14
14
|
const travellerMemory = defineMemory({
|
|
15
15
|
description: "What we already know about this traveller",
|
|
16
|
-
context: {
|
|
16
|
+
context: {
|
|
17
|
+
adapter: ({ agentId }) =>
|
|
18
|
+
Effect.promise(() =>
|
|
19
|
+
FileContextAdapter.open(fileURLToPath(new URL(`${encodeURIComponent(agentId)}.json`, directory))),
|
|
20
|
+
),
|
|
21
|
+
},
|
|
17
22
|
});
|
|
18
23
|
|
|
19
24
|
export default travellerMemory;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { defineSharedTool } from "glove-foundry";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A shared tool is installable: it is in the agent's catalogue (composition.ts)
|
|
7
|
+
* but mounts only on instances that install it — see `instances.ts`, where the
|
|
8
|
+
* demo instance installs it with a home currency. One file, one default export,
|
|
9
|
+
* and the filename is the id: `convert-currency`.
|
|
10
|
+
*
|
|
11
|
+
* The rates are fixed sample values. Replace `RATES` with a live rate source.
|
|
12
|
+
*/
|
|
13
|
+
const RATES: Readonly<Record<string, number>> = { USD: 1, EUR: 0.92, GBP: 0.79, KES: 129 };
|
|
14
|
+
|
|
15
|
+
const convertCurrency = defineSharedTool({
|
|
16
|
+
description: "Convert a USD price into the traveller's home currency",
|
|
17
|
+
config: z.object({ home: z.enum(["USD", "EUR", "GBP", "KES"]).default("USD") }),
|
|
18
|
+
create: ({ config }) => Effect.sync(() => {
|
|
19
|
+
const rate = RATES[config.home]!;
|
|
20
|
+
return {
|
|
21
|
+
name: "convert_currency",
|
|
22
|
+
description: `Convert an amount in USD into ${config.home}, the traveller's home currency.`,
|
|
23
|
+
inputSchema: z.object({ amountUsd: z.number().describe("Price in USD") }),
|
|
24
|
+
async do({ amountUsd }: { amountUsd: number }) {
|
|
25
|
+
return {
|
|
26
|
+
status: "success" as const,
|
|
27
|
+
data: { currency: config.home, amount: Math.round(amountUsd * rate * 100) / 100, rate },
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export default convertCurrency;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { JsSession, defineFn } from "glove-js";
|
|
2
|
-
import { defineRepl, defineWorkingEnvironment } from "glove-foundry";
|
|
2
|
+
import { defineRepl, defineWorkingEnvironment, foundryDataEnvironmentPersistence } from "glove-foundry";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -11,6 +11,9 @@ import { z } from "zod";
|
|
|
11
11
|
*/
|
|
12
12
|
export const conciergeWorkspace = defineWorkingEnvironment({
|
|
13
13
|
options: { limits: { maxVfsBytes: 16 * 1024 * 1024 } },
|
|
14
|
+
// The environment closes after every run; this snapshots its files into the
|
|
15
|
+
// data adapter and restores them on the next run of the same instance.
|
|
16
|
+
persistence: foundryDataEnvironmentPersistence({ scope: "agent" }),
|
|
14
17
|
});
|
|
15
18
|
|
|
16
19
|
export function conciergeRepl(actor: string, budgetUsd: number) {
|
|
@@ -1,18 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FileFoundryDataAdapter, defineApplication } from "glove-foundry";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { conciergeDemo, conciergeDemoReplies } from "./agents/concierge/instances.js";
|
|
2
4
|
import { travellerAccount, travellerInbound, travellerOutbound } from "./agents/concierge/topology.js";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* The application is where runtime data enters: which adapter persists
|
|
6
|
-
* instances, which accounts and
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* instances, which accounts, routes and bindings exist.
|
|
9
|
+
*
|
|
10
|
+
* Instances, conversations, schedules, workspace data and the concierge's VFS
|
|
11
|
+
* are saved to `.data/foundry.json`, so they survive restarts — including the
|
|
12
|
+
* restart `dev` performs every time you save a file. `conciergeDemo` is seeded
|
|
13
|
+
* on first start. Delete `.data/` to start fresh. Use a database-backed adapter
|
|
14
|
+
* when several hosts share one runtime.
|
|
9
15
|
*/
|
|
10
|
-
export const data = new
|
|
16
|
+
export const data = new FileFoundryDataAdapter({
|
|
17
|
+
file: fileURLToPath(new URL("./.data/foundry.json", import.meta.url)),
|
|
18
|
+
agents: [conciergeDemo],
|
|
19
|
+
});
|
|
11
20
|
|
|
12
21
|
export default defineApplication({
|
|
13
22
|
name: "{{projectName}}",
|
|
14
23
|
data,
|
|
15
24
|
accounts: [travellerAccount],
|
|
16
25
|
routes: [travellerInbound, travellerOutbound],
|
|
17
|
-
bindings: [],
|
|
26
|
+
bindings: [conciergeDemoReplies],
|
|
18
27
|
});
|
|
@@ -12,8 +12,41 @@ import type {
|
|
|
12
12
|
* project runs end to end — real runs, real tool calls, a real event trace in
|
|
13
13
|
* the inspector — before you have configured a provider.
|
|
14
14
|
*
|
|
15
|
+
* Each turn it searches flights, then answers. When the message arrived over
|
|
16
|
+
* the chat transport (see README, "Send it a chat message"), it also replies
|
|
17
|
+
* through the installed calendar app's messaging tool, which is what a real
|
|
18
|
+
* model does when the playbook asks it to.
|
|
19
|
+
*
|
|
15
20
|
* Delete this file and the `model()` fallback in agent.ts once you have a key.
|
|
16
21
|
*/
|
|
22
|
+
const SEND_TOOL = "glove_app_calendar__messaging_send";
|
|
23
|
+
|
|
24
|
+
interface Inbound {
|
|
25
|
+
readonly threadId: string;
|
|
26
|
+
readonly text: string;
|
|
27
|
+
readonly routeId: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function unescapeXml(value: string): string {
|
|
31
|
+
return value
|
|
32
|
+
.replaceAll(""", '"')
|
|
33
|
+
.replaceAll("'", "'")
|
|
34
|
+
.replaceAll("<", "<")
|
|
35
|
+
.replaceAll(">", ">")
|
|
36
|
+
.replaceAll("&", "&");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read the event Foundry serialized into the turn for an inbound transmission. */
|
|
40
|
+
function inboundFrom(text: string): Inbound | undefined {
|
|
41
|
+
if (!text.includes('<transmission direction="inbound"')) return undefined;
|
|
42
|
+
const payload = /<payload format="json">([\s\S]*?)<\/payload>/.exec(text)?.[1];
|
|
43
|
+
const routeId = /<outbound route="([^"]+)"/.exec(text)?.[1];
|
|
44
|
+
if (!payload || !routeId) return undefined;
|
|
45
|
+
const event = JSON.parse(unescapeXml(payload)) as { threadId?: string; text?: string };
|
|
46
|
+
if (!event.threadId) return undefined;
|
|
47
|
+
return { threadId: event.threadId, text: event.text ?? "", routeId: unescapeXml(routeId) };
|
|
48
|
+
}
|
|
49
|
+
|
|
17
50
|
export class DemoModel implements ModelAdapter {
|
|
18
51
|
readonly name = "demo-model";
|
|
19
52
|
private systemPrompt = "";
|
|
@@ -26,38 +59,49 @@ export class DemoModel implements ModelAdapter {
|
|
|
26
59
|
request: PromptRequest,
|
|
27
60
|
notify: NotifySubscribersFunction,
|
|
28
61
|
): Promise<ModelPromptResult> {
|
|
29
|
-
|
|
30
|
-
|
|
62
|
+
// Only this turn matters: everything from the latest user message onward.
|
|
63
|
+
let userIndex = request.messages.length - 1;
|
|
64
|
+
while (userIndex >= 0) {
|
|
65
|
+
const message = request.messages[userIndex]!;
|
|
66
|
+
if (message.sender === "user" && !message.tool_results?.length && !message.framework_context) break;
|
|
67
|
+
userIndex--;
|
|
68
|
+
}
|
|
69
|
+
const turn = request.messages.slice(Math.max(userIndex, 0));
|
|
70
|
+
const asked = request.messages[userIndex]?.text ?? "";
|
|
71
|
+
const called = new Set(
|
|
72
|
+
turn.flatMap((message) => message.tool_calls?.map((call) => call.tool_name) ?? []),
|
|
31
73
|
);
|
|
74
|
+
const inbound = inboundFrom(asked);
|
|
75
|
+
const answer = [
|
|
76
|
+
`Here is what I found for "${inbound?.text ?? asked}".`,
|
|
77
|
+
"Three flights match. Meridian at 13:15 is the cheapest at $494; Northwind at 22:05 is the fastest at 9h.",
|
|
78
|
+
"Tell me your dates and I will check the calendar and hold them.",
|
|
79
|
+
].join(" ");
|
|
32
80
|
|
|
33
|
-
if (!
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
tokens_out: 6,
|
|
81
|
+
if (!called.has("find_flights")) {
|
|
82
|
+
return this.callTool(notify, "call_demo_flights", "find_flights", {
|
|
83
|
+
from: "LIS",
|
|
84
|
+
to: "NBO",
|
|
85
|
+
maxPrice: 5_000,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const canReply = request.tools?.some((tool) => tool.name === SEND_TOOL) ?? false;
|
|
90
|
+
if (inbound && canReply && !called.has(SEND_TOOL)) {
|
|
91
|
+
return this.callTool(notify, "call_demo_reply", SEND_TOOL, {
|
|
92
|
+
routeId: inbound.routeId,
|
|
93
|
+
payload: { threadId: inbound.threadId, text: answer },
|
|
47
94
|
});
|
|
48
|
-
const message: Message = { sender: "agent", text: "", tool_calls: [call] };
|
|
49
|
-
return { messages: [message], tokens_in: 14, tokens_out: 6 };
|
|
50
95
|
}
|
|
51
96
|
|
|
52
|
-
const asked = [...request.messages]
|
|
53
|
-
.reverse()
|
|
54
|
-
.find((message) => message.sender === "user" && !message.tool_results?.length)?.text;
|
|
55
97
|
const text = [
|
|
56
|
-
|
|
57
|
-
"Three flights match. Meridian at 13:15 is the cheapest at $494; Northwind at 22:05 is the fastest at 9h.",
|
|
58
|
-
"Tell me your dates and I will check the calendar and hold them.",
|
|
98
|
+
answer,
|
|
59
99
|
"",
|
|
60
|
-
|
|
100
|
+
inbound
|
|
101
|
+
? canReply
|
|
102
|
+
? "(Replied to the traveller's chat thread — see the [telegram] line in the terminal.)"
|
|
103
|
+
: "(This instance cannot reply over chat: install the calendar app and add a binding, as concierge-demo has.)"
|
|
104
|
+
: "(This is the built-in demo model. Set OPENROUTER_API_KEY in .env.local for real answers.)",
|
|
61
105
|
].join("\n");
|
|
62
106
|
await notify("text_delta", { text });
|
|
63
107
|
await notify("model_response_complete", {
|
|
@@ -69,4 +113,24 @@ export class DemoModel implements ModelAdapter {
|
|
|
69
113
|
void this.systemPrompt;
|
|
70
114
|
return { messages: [{ sender: "agent", text }], tokens_in: 32, tokens_out: 48 };
|
|
71
115
|
}
|
|
116
|
+
|
|
117
|
+
private async callTool(
|
|
118
|
+
notify: NotifySubscribersFunction,
|
|
119
|
+
id: string,
|
|
120
|
+
name: string,
|
|
121
|
+
input: Record<string, unknown>,
|
|
122
|
+
): Promise<ModelPromptResult> {
|
|
123
|
+
// ToolCall's fields are optional, so notify() reads the local values.
|
|
124
|
+
const call: ToolCall = { id, tool_name: name, input_args: input };
|
|
125
|
+
await notify("tool_use", { id, name, input });
|
|
126
|
+
await notify("model_response_complete", {
|
|
127
|
+
text: "",
|
|
128
|
+
tool_calls: [call],
|
|
129
|
+
stop_reason: "tool_use",
|
|
130
|
+
tokens_in: 14,
|
|
131
|
+
tokens_out: 6,
|
|
132
|
+
});
|
|
133
|
+
const message: Message = { sender: "agent", text: "", tool_calls: [call] };
|
|
134
|
+
return { messages: [message], tokens_in: 14, tokens_out: 6 };
|
|
135
|
+
}
|
|
72
136
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
InMemoryContextAdapter,
|
|
5
|
+
MemorySchema,
|
|
6
|
+
type ContextEntryInput,
|
|
7
|
+
type ContextEntryPatch,
|
|
8
|
+
type Provenance,
|
|
9
|
+
} from "glove-memory";
|
|
10
|
+
|
|
11
|
+
type ContextMemoryState = ReturnType<InMemoryContextAdapter["snapshot"]>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ambient context (preferences, home airport, who is travelling) saved to a
|
|
15
|
+
* JSON file. Like transcripts, memory has to live outside the run's process to
|
|
16
|
+
* be there on the next message. Single host; use a database adapter for more.
|
|
17
|
+
*/
|
|
18
|
+
export class FileContextAdapter extends InMemoryContextAdapter {
|
|
19
|
+
private constructor(private readonly file: string, state?: ContextMemoryState) {
|
|
20
|
+
super({ schema: new MemorySchema(), identifier: `file-context:${file}`, ...(state ? { state } : {}) });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static async open(file: string): Promise<FileContextAdapter> {
|
|
24
|
+
try {
|
|
25
|
+
return new FileContextAdapter(file, JSON.parse(await readFile(file, "utf8")) as ContextMemoryState);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return new FileContextAdapter(file);
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private async save(): Promise<void> {
|
|
33
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
34
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
35
|
+
await writeFile(temporary, JSON.stringify(this.snapshot()));
|
|
36
|
+
await rename(temporary, this.file);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
override async set(entry: ContextEntryInput, provenance: Provenance) {
|
|
40
|
+
const result = await super.set(entry, provenance);
|
|
41
|
+
await this.save();
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
override async update(id: string, patch: ContextEntryPatch, provenance: Provenance) {
|
|
45
|
+
await super.update(id, patch, provenance);
|
|
46
|
+
await this.save();
|
|
47
|
+
}
|
|
48
|
+
override async unset(id: string, provenance: Provenance) {
|
|
49
|
+
await super.unset(id, provenance);
|
|
50
|
+
await this.save();
|
|
51
|
+
}
|
|
52
|
+
override async setSection(section: string, entries: Array<Omit<ContextEntryInput, "section">>, provenance: Provenance) {
|
|
53
|
+
await super.setSection(section, entries, provenance);
|
|
54
|
+
await this.save();
|
|
55
|
+
}
|
|
56
|
+
override async unsetSection(section: string, provenance: Provenance) {
|
|
57
|
+
await super.unsetSection(section, provenance);
|
|
58
|
+
await this.save();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import {
|
|
6
|
+
MemoryStore,
|
|
7
|
+
type InboxItem,
|
|
8
|
+
type Message,
|
|
9
|
+
type Task,
|
|
10
|
+
type TokenConsumptionCounter,
|
|
11
|
+
} from "glove-core";
|
|
12
|
+
|
|
13
|
+
/** Transcripts live in `.data/transcripts`, beside this project's `lib` folder. */
|
|
14
|
+
const TRANSCRIPTS = fileURLToPath(new URL("../.data/transcripts/", import.meta.url));
|
|
15
|
+
|
|
16
|
+
interface SavedConversation {
|
|
17
|
+
readonly messages: Message[];
|
|
18
|
+
readonly turns: number;
|
|
19
|
+
readonly tokens: TokenConsumptionCounter;
|
|
20
|
+
readonly tasks: Task[];
|
|
21
|
+
readonly inbox: InboxItem[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Foundry runs every message in a fresh process, so a plain `MemoryStore`
|
|
26
|
+
* would start each turn with no history. This one loads the conversation from
|
|
27
|
+
* a JSON file and rewrites it after every change.
|
|
28
|
+
*
|
|
29
|
+
* One host, one active run per conversation. Replace it with a database-backed
|
|
30
|
+
* StoreAdapter when several hosts share conversations.
|
|
31
|
+
*/
|
|
32
|
+
export class FileStore extends MemoryStore {
|
|
33
|
+
private restoring = false;
|
|
34
|
+
|
|
35
|
+
private constructor(conversationId: string, private readonly file: string) {
|
|
36
|
+
super(conversationId);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static async open(conversationId: string, directory = TRANSCRIPTS): Promise<FileStore> {
|
|
40
|
+
const name = createHash("sha256").update(conversationId).digest("hex").slice(0, 32);
|
|
41
|
+
const store = new FileStore(conversationId, join(directory, `${name}.json`));
|
|
42
|
+
await store.restore();
|
|
43
|
+
return store;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private async restore(): Promise<void> {
|
|
47
|
+
let saved: SavedConversation;
|
|
48
|
+
try {
|
|
49
|
+
saved = JSON.parse(await readFile(this.file, "utf8")) as SavedConversation;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
this.restoring = true;
|
|
55
|
+
try {
|
|
56
|
+
await this.appendMessages(saved.messages);
|
|
57
|
+
await this.addTokens(saved.tokens);
|
|
58
|
+
for (let turn = 0; turn < saved.turns; turn++) await this.incrementTurn();
|
|
59
|
+
await this.addTasks(saved.tasks);
|
|
60
|
+
for (const item of saved.inbox) await this.addInboxItem(item);
|
|
61
|
+
} finally {
|
|
62
|
+
this.restoring = false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async save(): Promise<void> {
|
|
67
|
+
if (this.restoring) return;
|
|
68
|
+
const saved: SavedConversation = {
|
|
69
|
+
messages: await this.getMessages(),
|
|
70
|
+
turns: await this.getTurnCount(),
|
|
71
|
+
tokens: await this.getTokenConsumption(),
|
|
72
|
+
tasks: await this.getTasks(),
|
|
73
|
+
inbox: await this.getInboxItems(),
|
|
74
|
+
};
|
|
75
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
76
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
77
|
+
await writeFile(temporary, JSON.stringify(saved));
|
|
78
|
+
await rename(temporary, this.file);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
override async appendMessages(messages: Message[]) { await super.appendMessages(messages); await this.save(); }
|
|
82
|
+
override async addTokens(usage: TokenConsumptionCounter) { await super.addTokens(usage); await this.save(); }
|
|
83
|
+
override async incrementTurn() { await super.incrementTurn(); await this.save(); }
|
|
84
|
+
override async resetCounters() { await super.resetCounters(); await this.save(); }
|
|
85
|
+
override async addTasks(tasks: Task[]) { await super.addTasks(tasks); await this.save(); }
|
|
86
|
+
override async updateTask(id: string, updates: Partial<Pick<Task, "status" | "content" | "activeForm">>) {
|
|
87
|
+
await super.updateTask(id, updates);
|
|
88
|
+
await this.save();
|
|
89
|
+
}
|
|
90
|
+
override async addInboxItem(item: InboxItem) { await super.addInboxItem(item); await this.save(); }
|
|
91
|
+
override async updateInboxItem(id: string, updates: Partial<Pick<InboxItem, "status" | "response" | "resolved_at">>) {
|
|
92
|
+
await super.updateInboxItem(id, updates);
|
|
93
|
+
await this.save();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { defineSharedTool } from "glove-foundry";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
|
|
4
|
-
/** One file, one default export. The filename is the id: `current-time`. */
|
|
5
|
-
const currentTime = defineSharedTool({
|
|
6
|
-
description: "Return the current ISO timestamp",
|
|
7
|
-
tool: {
|
|
8
|
-
name: "current_time",
|
|
9
|
-
description: "Return the current ISO timestamp",
|
|
10
|
-
inputSchema: z.object({}),
|
|
11
|
-
async do() {
|
|
12
|
-
return { status: "success" as const, data: new Date().toISOString() };
|
|
13
|
-
},
|
|
14
|
-
},
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
export default currentTime;
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { defineSharedTool } from "glove-foundry";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* A shared tool is the smallest Foundry capability: one file, one default
|
|
6
|
-
* export, and an id taken from the filename (`find-flights`). Nothing here
|
|
7
|
-
* knows which agent will mount it.
|
|
8
|
-
*
|
|
9
|
-
* The sample data is deliberately local so a fresh project runs offline.
|
|
10
|
-
* Replace `search` with a call to your own inventory or a provider API.
|
|
11
|
-
*/
|
|
12
|
-
const SAMPLE_FLIGHTS = [
|
|
13
|
-
{ carrier: "Kestrel Air", from: "LIS", to: "NBO", depart: "08:40", hours: 9.5, price: 612 },
|
|
14
|
-
{ carrier: "Meridian", from: "LIS", to: "NBO", depart: "13:15", hours: 11, price: 494 },
|
|
15
|
-
{ carrier: "Northwind", from: "LIS", to: "NBO", depart: "22:05", hours: 9, price: 738 },
|
|
16
|
-
] as const;
|
|
17
|
-
|
|
18
|
-
function search(from: string, to: string, maxPrice: number) {
|
|
19
|
-
const origin = from.trim().toUpperCase();
|
|
20
|
-
const destination = to.trim().toUpperCase();
|
|
21
|
-
return SAMPLE_FLIGHTS.filter((flight) =>
|
|
22
|
-
(!origin || flight.from === origin) &&
|
|
23
|
-
(!destination || flight.to === destination) &&
|
|
24
|
-
flight.price <= maxPrice
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const findFlights = defineSharedTool({
|
|
29
|
-
description: "Search available flights between two airports",
|
|
30
|
-
tool: {
|
|
31
|
-
name: "find_flights",
|
|
32
|
-
description:
|
|
33
|
-
"Search flights between two IATA airport codes. Returns carrier, departure time, duration and price.",
|
|
34
|
-
inputSchema: z.object({
|
|
35
|
-
from: z.string().describe("Origin IATA code, for example LIS"),
|
|
36
|
-
to: z.string().describe("Destination IATA code, for example NBO"),
|
|
37
|
-
maxPrice: z.number().default(5_000).describe("Highest acceptable price in USD"),
|
|
38
|
-
}),
|
|
39
|
-
async do({ from, to, maxPrice }) {
|
|
40
|
-
const matches = search(from, to, maxPrice);
|
|
41
|
-
if (matches.length === 0) {
|
|
42
|
-
return { status: "success" as const, data: { matches: [], note: "No flight matched those constraints." } };
|
|
43
|
-
}
|
|
44
|
-
return { status: "success" as const, data: { matches } };
|
|
45
|
-
},
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
export default findFlights;
|