@intentius/chant-lexicon-fountain 0.88.0 → 0.90.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/composites/box.d.ts +123 -0
- package/dist/composites/box.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +3 -3
- package/dist/manifest.json +1 -1
- package/dist/op/activities/fountain-run.d.ts +77 -6
- package/dist/op/activities/fountain-run.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/skills/chant-fountain-locked-sandboxes.md +1 -1
- package/package.json +2 -2
- package/src/composites/box.ts +197 -0
- package/src/composites/composites.test.ts +133 -0
- package/src/index.ts +2 -0
- package/src/op/activities/fountain-apply.test.ts +0 -54
- package/src/op/activities/fountain-run.test.ts +219 -0
- package/src/op/activities/fountain-run.ts +136 -18
- package/src/op/activities/index.ts +8 -2
- package/src/skills/chant-fountain-locked-sandboxes.md +1 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box — a persistent machine that serves an app repo and its tools (#2705).
|
|
3
|
+
*
|
|
4
|
+
* A box is an `Environment` whose repositories hold the app repo and whose
|
|
5
|
+
* `setup_script` provisions the machine (for an arugula studio box, the
|
|
6
|
+
* studio's `box/provision-template.sh`), an `Agent` on a persistent sandbox
|
|
7
|
+
* provisioned from it, and optionally a `Vault` for the secrets every box of
|
|
8
|
+
* this kind shares.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* export const { environment, agent, vault } = Box({
|
|
12
|
+
* name: "studio-box",
|
|
13
|
+
* repo: { url: "https://github.com/arugula-salad/studio", ref: "main" },
|
|
14
|
+
* setupScript: readFileSync("box/provision-template.sh", "utf8"),
|
|
15
|
+
* permissionPolicy: { default: "auto_allow" },
|
|
16
|
+
* allowedHosts: ["registry.npmjs.org", "github.com"],
|
|
17
|
+
* vault: { secrets: [{ key: "STUDIO_SECRET", value: process.env.STUDIO_SECRET! }] },
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Beside `Steward`: a steward is an environment's one writer, an `acp` agent
|
|
22
|
+
* whose every turn is a chant command line, bound to a teammate with a
|
|
23
|
+
* standing thread and schedules. A box is where people and their agents work
|
|
24
|
+
* on an app: a conversational runtime (`claude` by default), the app repo
|
|
25
|
+
* cloned in, the provisioning script run as setup, and one served port. It
|
|
26
|
+
* declares no teammate and no schedule, and nothing here starts a
|
|
27
|
+
* conversation. Starting and reaping a box stays a runtime act, as it is in
|
|
28
|
+
* hud's box runtime.
|
|
29
|
+
*
|
|
30
|
+
* The defaults are the closed ones, as `ConciergeStack`'s are, and loosening
|
|
31
|
+
* one is a visible parameter: networking is `limited` with an empty allowlist
|
|
32
|
+
* until `allowedHosts` names hosts or `unrestrictedNetworking: true` opens it
|
|
33
|
+
* (which FTN011 then warns about); `allowed_vault_ids` holds the box's own
|
|
34
|
+
* vault, or nothing, until `allowedVaults` widens it. `permission_policy` has
|
|
35
|
+
* no default at all, because fountain's unset policy is `auto_allow` and that
|
|
36
|
+
* should be a choice someone wrote down.
|
|
37
|
+
*
|
|
38
|
+
* ## The port
|
|
39
|
+
*
|
|
40
|
+
* fountain's `Environment` and `Agent` have no field for a served port (the
|
|
41
|
+
* pinned spec, v0.21.0). The port is recorded in the metadata of both, under
|
|
42
|
+
* `box-port`, and returned as `port`, so whatever builds the box's URL reads
|
|
43
|
+
* it from the declaration or from fountain's own record without a guess.
|
|
44
|
+
*/
|
|
45
|
+
import { Agent, Environment, Vault } from "../generated/index.js";
|
|
46
|
+
/** The metadata key a box's served port is recorded under, on its Environment and Agent. */
|
|
47
|
+
export declare const BOX_PORT_METADATA_KEY = "box-port";
|
|
48
|
+
/** The port a box serves when none is given: the door's. */
|
|
49
|
+
export declare const BOX_DEFAULT_PORT = 8080;
|
|
50
|
+
/** Where the app repo is cloned when no `mountPath` is given. */
|
|
51
|
+
export declare const BOX_DEFAULT_MOUNT_PATH = "/workspace/app";
|
|
52
|
+
/** A permission verdict, or a number of seconds under the `ask_timeout` key. */
|
|
53
|
+
export type BoxPermissionPolicy = Record<string, "ask" | "auto_allow" | "auto_deny" | number>;
|
|
54
|
+
/** The app repo the box serves. Shape of fountain's `Repository`. */
|
|
55
|
+
export interface BoxRepositoryOpts {
|
|
56
|
+
/** https clone url. */
|
|
57
|
+
url: string;
|
|
58
|
+
/** Absolute path the repo is cloned to. Default `/workspace/app`. */
|
|
59
|
+
mountPath?: string;
|
|
60
|
+
/** Branch or tag. The default branch when omitted. */
|
|
61
|
+
ref?: string;
|
|
62
|
+
/** Name of the secret holding a clone token. Required for a private repo. */
|
|
63
|
+
secretKey?: string;
|
|
64
|
+
}
|
|
65
|
+
/** The secrets every box of this kind shares, held in a Vault of the box's own. */
|
|
66
|
+
export interface BoxVaultOpts {
|
|
67
|
+
/** Vault name. Default `<name>-secrets`. */
|
|
68
|
+
name?: string;
|
|
69
|
+
description?: string;
|
|
70
|
+
/** Written at apply. A reference that resolves at build, never a literal (FTN001). */
|
|
71
|
+
secrets?: {
|
|
72
|
+
key: string;
|
|
73
|
+
value: string;
|
|
74
|
+
}[];
|
|
75
|
+
}
|
|
76
|
+
export interface BoxOpts {
|
|
77
|
+
/** The Agent's name. The Environment is `<name>-env`. */
|
|
78
|
+
name: string;
|
|
79
|
+
/** The app repo the box serves, cloned into the sandbox before setup runs. */
|
|
80
|
+
repo: BoxRepositoryOpts;
|
|
81
|
+
/** The provisioning script's text, run as the Environment's `setup_script`. */
|
|
82
|
+
setupScript: string;
|
|
83
|
+
/** Setup exec timeout, 1 to 900 seconds (FTN024). fountain's default is 120. */
|
|
84
|
+
setupTimeoutSeconds?: number;
|
|
85
|
+
/** Agent runtime. Default `claude`. */
|
|
86
|
+
runtime?: "claude" | "codex" | "gemini" | "opencode";
|
|
87
|
+
/** Canonical provider/model_id. Omitted, fountain's default for the runtime. */
|
|
88
|
+
model?: string;
|
|
89
|
+
/** Per-tool permission policy. Required: fountain's unset policy is `auto_allow`. */
|
|
90
|
+
permissionPolicy: BoxPermissionPolicy;
|
|
91
|
+
/** Environment packages, passed through. */
|
|
92
|
+
packages?: Record<string, unknown>;
|
|
93
|
+
/** Environment variables, passed through. Not for secrets: use `vault`. */
|
|
94
|
+
envVars?: Record<string, string>;
|
|
95
|
+
/** Egress allowlist under `limited` networking. Default [], deny-all. */
|
|
96
|
+
allowedHosts?: string[];
|
|
97
|
+
/** Open the sandbox's network. FTN011 warns on it. Refused with `allowedHosts`. */
|
|
98
|
+
unrestrictedNetworking?: boolean;
|
|
99
|
+
/** Declare a Vault for the box's shared secrets. */
|
|
100
|
+
vault?: BoxVaultOpts;
|
|
101
|
+
/**
|
|
102
|
+
* Vaults a conversation may attach. Default: the box's own vault, or none.
|
|
103
|
+
* `"any"` leaves the list unset, which fountain reads as any vault the
|
|
104
|
+
* tenant owns: what a runtime that makes a vault per box (hud's) needs.
|
|
105
|
+
*/
|
|
106
|
+
allowedVaults?: "any" | Array<InstanceType<typeof Vault> | string>;
|
|
107
|
+
/** The port the box serves. Default 8080, the door's. Recorded as `box-port` metadata. */
|
|
108
|
+
port?: number;
|
|
109
|
+
system?: string;
|
|
110
|
+
skills?: Array<Record<string, unknown>>;
|
|
111
|
+
mcpServers?: Record<string, unknown>;
|
|
112
|
+
/** Extra metadata, merged over the ownership marker on every resource the box declares. */
|
|
113
|
+
metadata?: Record<string, unknown>;
|
|
114
|
+
}
|
|
115
|
+
export interface BoxResources {
|
|
116
|
+
environment: InstanceType<typeof Environment>;
|
|
117
|
+
agent: InstanceType<typeof Agent>;
|
|
118
|
+
vault?: InstanceType<typeof Vault>;
|
|
119
|
+
/** The served port, as recorded under `box-port`. */
|
|
120
|
+
port: number;
|
|
121
|
+
}
|
|
122
|
+
export declare function Box(opts: BoxOpts): BoxResources;
|
|
123
|
+
//# sourceMappingURL=box.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"box.d.ts","sourceRoot":"","sources":["../../src/composites/box.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAE/D,4FAA4F;AAC5F,eAAO,MAAM,qBAAqB,aAAa,CAAC;AAEhD,4DAA4D;AAC5D,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAErC,iEAAiE;AACjE,eAAO,MAAM,sBAAsB,mBAAmB,CAAC;AAEvD,gFAAgF;AAChF,MAAM,MAAM,mBAAmB,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,GAAG,YAAY,GAAG,WAAW,GAAG,MAAM,CAAC,CAAC;AAE9F,qEAAqE;AACrE,MAAM,WAAW,iBAAiB;IAChC,uBAAuB;IACvB,GAAG,EAAE,MAAM,CAAC;IACZ,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sDAAsD;IACtD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,mFAAmF;AACnF,MAAM,WAAW,YAAY;IAC3B,4CAA4C;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sFAAsF;IACtF,OAAO,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC5C;AAED,MAAM,WAAW,OAAO;IACtB,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,IAAI,EAAE,iBAAiB,CAAC;IACxB,+EAA+E;IAC/E,WAAW,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,uCAAuC;IACvC,OAAO,CAAC,EAAE,QAAQ,GAAG,OAAO,GAAG,QAAQ,GAAG,UAAU,CAAC;IACrD,gFAAgF;IAChF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qFAAqF;IACrF,gBAAgB,EAAE,mBAAmB,CAAC;IACtC,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,yEAAyE;IACzE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,mFAAmF;IACnF,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,oDAAoD;IACpD,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB;;;;OAIG;IACH,aAAa,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC,YAAY,CAAC,OAAO,KAAK,CAAC,GAAG,MAAM,CAAC,CAAC;IACnE,0FAA0F;IAC1F,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACxC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,2FAA2F;IAC3F,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,YAAY,CAAC,OAAO,WAAW,CAAC,CAAC;IAC9C,KAAK,EAAE,YAAY,CAAC,OAAO,KAAK,CAAC,CAAC;IAClC,KAAK,CAAC,EAAE,YAAY,CAAC,OAAO,KAAK,CAAC,CAAC;IACnC,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAgB,GAAG,CAAC,IAAI,EAAE,OAAO,GAAG,YAAY,CAoE/C"}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export { ConciergeStack } from "./composites/concierge-stack.js";
|
|
|
14
14
|
export type { ConciergeStackOpts, ConciergeStackResources } from "./composites/concierge-stack.js";
|
|
15
15
|
export { Steward, stewardForOp, STEWARD_RUNTIME_COMMAND } from "./composites/steward.js";
|
|
16
16
|
export type { StewardOp, StewardOpts, StewardResources, StewardWebhookOpts } from "./composites/steward.js";
|
|
17
|
+
export { Box, BOX_PORT_METADATA_KEY, BOX_DEFAULT_PORT, BOX_DEFAULT_MOUNT_PATH } from "./composites/box.js";
|
|
18
|
+
export type { BoxOpts, BoxResources, BoxRepositoryOpts, BoxVaultOpts, BoxPermissionPolicy } from "./composites/box.js";
|
|
17
19
|
export { acpCommandGroup } from "./acp/index.js";
|
|
18
20
|
export { AcpServer } from "./acp/server.js";
|
|
19
21
|
export type { AcpServerOptions } from "./acp/server.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAIlD,OAAO,EAAE,4BAA4B,EAAE,MAAM,gBAAgB,CAAC;AAC9D,YAAY,EAAE,0BAA0B,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,EACL,8BAA8B,EAC9B,sBAAsB,EACtB,iBAAiB,GAClB,MAAM,sBAAsB,CAAC;AAG9B,cAAc,mBAAmB,CAAC;AAIlC,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,yBAAyB,EAAE,MAAM,iBAAiB,CAAC;AACxF,YAAY,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAInH,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAChE,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAIhE,OAAO,EACL,uBAAuB,EACvB,kBAAkB,EAClB,8BAA8B,GAC/B,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,wBAAwB,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAG5F,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,YAAY,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AAChG,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AACtF,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAIlD,OAAO,EAAE,4BAA4B,EAAE,MAAM,gBAAgB,CAAC;AAC9D,YAAY,EAAE,0BAA0B,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,EACL,8BAA8B,EAC9B,sBAAsB,EACtB,iBAAiB,GAClB,MAAM,sBAAsB,CAAC;AAG9B,cAAc,mBAAmB,CAAC;AAIlC,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,yBAAyB,EAAE,MAAM,iBAAiB,CAAC;AACxF,YAAY,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAInH,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAChE,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAIhE,OAAO,EACL,uBAAuB,EACvB,kBAAkB,EAClB,8BAA8B,GAC/B,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,wBAAwB,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAG5F,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,YAAY,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AAChG,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AACtF,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AACzG,OAAO,EAAE,GAAG,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AACxG,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAIpH,OAAO,EAAE,eAAe,EAAE,MAAM,OAAO,CAAC;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,YAAY,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAC7C,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "574b2bc5f0f9b2c0125e34c21d47fbdc1f154e558f9ff61e585a41b0c2c913da",
|
|
5
5
|
"meta.json": "3301437a2a56eaf1a5cc8f6eabb18a40767f916e09701b2d9bf20aba4066738c",
|
|
6
6
|
"types/index.d.ts": "cf6b634fe0a8e9e96f1d9d07e3df53838def2683dc93dbe3c7fd971c6d3908ae",
|
|
7
7
|
"rules/ftn001-no-secret-literals.ts": "a417b194cd7039911cca9a0f14c49b042dfa6b1ee48517f7c2e22b16590a8599",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"skills/chant-fountain.md": "906a312756a02c88de634915f63ab4dfe1b1078ff4d8cfb4b6dc97eec14bc237",
|
|
22
22
|
"skills/chant-fountain-secrets.md": "40aa847d62547581bebdde630b54796c4e1cbf498869392e0d396d96a75c0ab6",
|
|
23
23
|
"skills/chant-fountain-ops.md": "4e72f0a0c7477a9e98f85ea45e49ba69e8a699f9c13ba0f0cf1cb20a88151373",
|
|
24
|
-
"skills/chant-fountain-locked-sandboxes.md": "
|
|
24
|
+
"skills/chant-fountain-locked-sandboxes.md": "14def4c68b13c2bd561f75819b188968cec85073a5dd8b59e91708e1af26daae"
|
|
25
25
|
},
|
|
26
|
-
"composite": "
|
|
26
|
+
"composite": "21bc34705dabf0dfe5a50d7bb81bf76d54cf4647b5f5790cd66e54de1d4f9a5f"
|
|
27
27
|
}
|
package/dist/manifest.json
CHANGED
|
@@ -1,15 +1,62 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* fountainRun — start a conversation from a declared Agent and follow it
|
|
3
|
-
* to
|
|
3
|
+
* to the end of its turn.
|
|
4
4
|
*
|
|
5
5
|
* Conversations are runs, not declarables (the one fountain kind the
|
|
6
6
|
* lexicon deliberately does not model as a resource). This op is the
|
|
7
|
-
* imperative half: resolve the agent by name, POST the conversation,
|
|
8
|
-
* poll until
|
|
9
|
-
*
|
|
7
|
+
* imperative half: resolve the agent by name, POST the conversation, and
|
|
8
|
+
* poll until the turn is done.
|
|
9
|
+
*
|
|
10
|
+
* What "done" means depends on the agent's `sandbox_mode` (#2718). An
|
|
11
|
+
* `ephemeral` conversation's machine is its own and this polls the
|
|
12
|
+
* conversation's own status to `completed | failed | timed_out |
|
|
13
|
+
* terminated` — none of which a running server's conversation status
|
|
14
|
+
* actually reaches on its own, so in practice this waits out `timeoutMs`
|
|
15
|
+
* and terminates, as it always has. A `persistent` agent's conversation
|
|
16
|
+
* (`Steward`, and `Box` from #2705) shares one machine across every
|
|
17
|
+
* conversation on it, and a turn ending there does not tear the sandbox
|
|
18
|
+
* down: the conversation settles on `idle`, not on any of the four
|
|
19
|
+
* statuses above. Waiting for one of those on a persistent agent is the
|
|
20
|
+
* bug this op used to have — it waited out the whole deadline after the
|
|
21
|
+
* turn had already finished, then terminated a machine that was meant to
|
|
22
|
+
* persist. For `idle`, this instead reads the finished turn back
|
|
23
|
+
* (`GET .../turns`) and returns its own outcome (`completed | failed |
|
|
24
|
+
* interrupted` — fountain's `end_turn` block, or an error) without
|
|
25
|
+
* terminating anything.
|
|
26
|
+
*
|
|
27
|
+
* Terminating the conversation's machine on deadline (a hung run should
|
|
28
|
+
* not outlive the op that started it) still happens whenever the turn has
|
|
29
|
+
* not ended — for either sandbox mode. `terminate` makes the choice
|
|
30
|
+
* explicit rather than leaving it implicit in `sandbox_mode`.
|
|
10
31
|
*/
|
|
11
32
|
import { type FountainHttp, type FountainConnectionDeps } from "./fountain-apply.js";
|
|
33
|
+
/**
|
|
34
|
+
* Conversation statuses that end an *ephemeral* agent's run. `completed`
|
|
35
|
+
* and `timed_out` are not values fountain's `Conversation.status` takes
|
|
36
|
+
* today (its enum is `pending | running | idle | failed | terminated`) —
|
|
37
|
+
* they are kept so a server that starts reporting them is handled without
|
|
38
|
+
* a further change here.
|
|
39
|
+
*/
|
|
12
40
|
export declare const TERMINAL_STATUSES: Set<string>;
|
|
41
|
+
/**
|
|
42
|
+
* Conversation statuses that end a *persistent* agent's run: everything
|
|
43
|
+
* {@link TERMINAL_STATUSES} does, plus `idle` — the status a persistent
|
|
44
|
+
* conversation settles on once its turn ends, machine still up (#2718).
|
|
45
|
+
*/
|
|
46
|
+
export declare const PERSISTENT_DONE_STATUSES: Set<string>;
|
|
47
|
+
/**
|
|
48
|
+
* How `fountainRun` treats the conversation's machine once its wait ends
|
|
49
|
+
* (#2718).
|
|
50
|
+
*
|
|
51
|
+
* - `on-deadline` — terminate only when `timeoutMs` is hit with the turn
|
|
52
|
+
* still unfinished, never on a turn that ended cleanly. This was
|
|
53
|
+
* `fountainRun`'s only behavior before this option existed.
|
|
54
|
+
* - `never` — never terminate, even past the deadline; the caller, or
|
|
55
|
+
* fountain's own idle/max-lifetime bounds, is responsible for the machine.
|
|
56
|
+
* - `always` — terminate whenever the wait ends, whether the turn ended
|
|
57
|
+
* cleanly or the deadline fired.
|
|
58
|
+
*/
|
|
59
|
+
export type TerminatePolicy = "never" | "on-deadline" | "always";
|
|
13
60
|
export interface FountainRunArgs {
|
|
14
61
|
/** Agent name (resolved against /api/agents) or a raw agent id. */
|
|
15
62
|
agent: string;
|
|
@@ -26,19 +73,43 @@ export interface FountainRunArgs {
|
|
|
26
73
|
profile?: string;
|
|
27
74
|
/** Project root `chant.config.ts` is read from. Default: process.cwd(). */
|
|
28
75
|
cwd?: string;
|
|
29
|
-
/** Give up
|
|
76
|
+
/** Give up after this long. Default 10 min. */
|
|
30
77
|
timeoutMs?: number;
|
|
31
78
|
/** Poll interval. Default 5s. */
|
|
32
79
|
pollMs?: number;
|
|
80
|
+
/**
|
|
81
|
+
* When to terminate the conversation's machine (#2718). Default follows
|
|
82
|
+
* the agent's `sandbox_mode`: `on-deadline` for `ephemeral` (unchanged),
|
|
83
|
+
* `never` for `persistent` — its machine is a home meant to outlive the
|
|
84
|
+
* conversation, so a run that hits its deadline leaves it be unless this
|
|
85
|
+
* says otherwise.
|
|
86
|
+
*/
|
|
87
|
+
terminate?: TerminatePolicy;
|
|
33
88
|
/** Injectable clock/sleep for tests. */
|
|
34
89
|
sleep?: (ms: number) => Promise<void>;
|
|
35
90
|
}
|
|
36
91
|
export interface FountainRunResult {
|
|
37
92
|
conversationId: string;
|
|
93
|
+
/**
|
|
94
|
+
* The run's outcome. For a persistent agent whose turn ended, this is the
|
|
95
|
+
* turn's own status (`completed | failed | interrupted`); otherwise it is
|
|
96
|
+
* the conversation's status when the wait ended (`terminated` on a
|
|
97
|
+
* deadline).
|
|
98
|
+
*/
|
|
38
99
|
status: string;
|
|
39
|
-
/** True when the
|
|
100
|
+
/** True when the agent's `sandbox_mode` resolved to `persistent`. */
|
|
101
|
+
persistent: boolean;
|
|
102
|
+
/** True when the op hit its deadline with the turn still unfinished. */
|
|
40
103
|
terminatedByDeadline: boolean;
|
|
41
104
|
}
|
|
105
|
+
export interface ResolvedAgent {
|
|
106
|
+
id: string;
|
|
107
|
+
/** `ephemeral` or `persistent`; fountain's own default is `ephemeral`. */
|
|
108
|
+
sandboxMode: string;
|
|
109
|
+
}
|
|
110
|
+
/** Resolve an agent by name or id, along with its `sandbox_mode`. */
|
|
111
|
+
export declare function resolveAgent(http: FountainHttp, agent: string): Promise<ResolvedAgent>;
|
|
112
|
+
/** Agent id only — the common case, and the one call sites outside this file use. */
|
|
42
113
|
export declare function resolveAgentId(http: FountainHttp, agent: string): Promise<string>;
|
|
43
114
|
export declare function fountainRun(args: FountainRunArgs, http?: FountainHttp, deps?: FountainConnectionDeps): Promise<FountainRunResult>;
|
|
44
115
|
//# sourceMappingURL=fountain-run.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fountain-run.d.ts","sourceRoot":"","sources":["../../../src/op/activities/fountain-run.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"fountain-run.d.ts","sourceRoot":"","sources":["../../../src/op/activities/fountain-run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAE1B;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,aAA8D,CAAC;AAE7F;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,aAA0C,CAAC;AAKhF;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,aAAa,GAAG,QAAQ,CAAC;AAEjE,MAAM,WAAW,eAAe;IAC9B,mEAAmE;IACnE,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,2EAA2E;IAC3E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iCAAiC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,wCAAwC;IACxC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,iBAAiB;IAChC,cAAc,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,UAAU,EAAE,OAAO,CAAC;IACpB,wEAAwE;IACxE,oBAAoB,EAAE,OAAO,CAAC;CAC/B;AAID,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,0EAA0E;IAC1E,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,qEAAqE;AACrE,wBAAsB,YAAY,CAAC,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAe5F;AAED,qFAAqF;AACrF,wBAAsB,cAAc,CAAC,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAGvF;AAeD,wBAAsB,WAAW,CAC/B,IAAI,EAAE,eAAe,EACrB,IAAI,CAAC,EAAE,YAAY,EACnB,IAAI,CAAC,EAAE,sBAAsB,GAC5B,OAAO,CAAC,iBAAiB,CAAC,CAwD5B"}
|
|
@@ -6,6 +6,6 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { fountainApply, resolveEndpoint, resolveToken, resolveConnection, parseManifest, toApplyPayload, isChantOwned, defaultFountainHttp, DEFAULT_FOUNTAIN_BASE_URL, OWNERSHIP_KEY, OWNERSHIP_VALUE, } from "./fountain-apply.js";
|
|
8
8
|
export type { FountainApplyArgs, FountainApplySummary, ManifestResource, FountainHttp, FountainConnectionDeps, } from "./fountain-apply.js";
|
|
9
|
-
export { fountainRun, resolveAgentId, TERMINAL_STATUSES } from "./fountain-run.js";
|
|
10
|
-
export type { FountainRunArgs, FountainRunResult } from "./fountain-run.js";
|
|
9
|
+
export { fountainRun, resolveAgent, resolveAgentId, TERMINAL_STATUSES, PERSISTENT_DONE_STATUSES, } from "./fountain-run.js";
|
|
10
|
+
export type { FountainRunArgs, FountainRunResult, ResolvedAgent, TerminatePolicy } from "./fountain-run.js";
|
|
11
11
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,aAAa,EACb,eAAe,EACf,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,YAAY,EACZ,mBAAmB,EACnB,yBAAyB,EACzB,aAAa,EACb,eAAe,GAChB,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACV,iBAAiB,EACjB,oBAAoB,EACpB,gBAAgB,EAChB,YAAY,EACZ,sBAAsB,GACvB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,aAAa,EACb,eAAe,EACf,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,YAAY,EACZ,mBAAmB,EACnB,yBAAyB,EACzB,aAAa,EACb,eAAe,GAChB,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACV,iBAAiB,EACjB,oBAAoB,EACpB,gBAAgB,EAChB,YAAY,EACZ,sBAAsB,GACvB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,WAAW,EACX,YAAY,EACZ,cAAc,EACd,iBAAiB,EACjB,wBAAwB,GACzB,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,eAAe,EAAE,iBAAiB,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC"}
|
|
@@ -31,4 +31,4 @@ The environment's config is the enforcement boundary, so watch it: `chant lifecy
|
|
|
31
31
|
|
|
32
32
|
## Running conversations
|
|
33
33
|
|
|
34
|
-
Conversations are runs, not resources. Use the `fountainRun` activity: resolves the agent by name, starts (optionally with a prompt and an allowlisted vault),
|
|
34
|
+
Conversations are runs, not resources. Use the `fountainRun` activity: resolves the agent by name, starts (optionally with a prompt and an allowlisted vault), and waits for the turn to end. What "end" means, and what happens to the machine, follows the agent's `sandbox_mode`: an `ephemeral` agent's conversation is polled for a terminal status and terminated on deadline, same as before; a `persistent` agent's (a `Steward` or `Box`) conversation settles on `idle` once its turn ends — machine still up for the next turn — so `fountainRun` reads the turn back and returns its outcome (`completed | failed | interrupted`) without terminating anything, by default. Pass `terminate: "on-deadline" | "always"` to opt a persistent agent's run back into ending the machine. Multi-turn interaction (follow-up prompts, interrupt) is fountain's own conversations API — keep chant to the lifecycle edges.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-fountain",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.90.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Fountain lexicon for chant — sandboxed agent environments, vaults, and agents as typed estate",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"bundle": "tsx src/package-cli.ts"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"@intentius/chant": "^0.
|
|
53
|
+
"@intentius/chant": "^0.90.0",
|
|
54
54
|
"typescript": "^5.9.3",
|
|
55
55
|
"zod": "^4.3.6"
|
|
56
56
|
},
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box — a persistent machine that serves an app repo and its tools (#2705).
|
|
3
|
+
*
|
|
4
|
+
* A box is an `Environment` whose repositories hold the app repo and whose
|
|
5
|
+
* `setup_script` provisions the machine (for an arugula studio box, the
|
|
6
|
+
* studio's `box/provision-template.sh`), an `Agent` on a persistent sandbox
|
|
7
|
+
* provisioned from it, and optionally a `Vault` for the secrets every box of
|
|
8
|
+
* this kind shares.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* export const { environment, agent, vault } = Box({
|
|
12
|
+
* name: "studio-box",
|
|
13
|
+
* repo: { url: "https://github.com/arugula-salad/studio", ref: "main" },
|
|
14
|
+
* setupScript: readFileSync("box/provision-template.sh", "utf8"),
|
|
15
|
+
* permissionPolicy: { default: "auto_allow" },
|
|
16
|
+
* allowedHosts: ["registry.npmjs.org", "github.com"],
|
|
17
|
+
* vault: { secrets: [{ key: "STUDIO_SECRET", value: process.env.STUDIO_SECRET! }] },
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Beside `Steward`: a steward is an environment's one writer, an `acp` agent
|
|
22
|
+
* whose every turn is a chant command line, bound to a teammate with a
|
|
23
|
+
* standing thread and schedules. A box is where people and their agents work
|
|
24
|
+
* on an app: a conversational runtime (`claude` by default), the app repo
|
|
25
|
+
* cloned in, the provisioning script run as setup, and one served port. It
|
|
26
|
+
* declares no teammate and no schedule, and nothing here starts a
|
|
27
|
+
* conversation. Starting and reaping a box stays a runtime act, as it is in
|
|
28
|
+
* hud's box runtime.
|
|
29
|
+
*
|
|
30
|
+
* The defaults are the closed ones, as `ConciergeStack`'s are, and loosening
|
|
31
|
+
* one is a visible parameter: networking is `limited` with an empty allowlist
|
|
32
|
+
* until `allowedHosts` names hosts or `unrestrictedNetworking: true` opens it
|
|
33
|
+
* (which FTN011 then warns about); `allowed_vault_ids` holds the box's own
|
|
34
|
+
* vault, or nothing, until `allowedVaults` widens it. `permission_policy` has
|
|
35
|
+
* no default at all, because fountain's unset policy is `auto_allow` and that
|
|
36
|
+
* should be a choice someone wrote down.
|
|
37
|
+
*
|
|
38
|
+
* ## The port
|
|
39
|
+
*
|
|
40
|
+
* fountain's `Environment` and `Agent` have no field for a served port (the
|
|
41
|
+
* pinned spec, v0.21.0). The port is recorded in the metadata of both, under
|
|
42
|
+
* `box-port`, and returned as `port`, so whatever builds the box's URL reads
|
|
43
|
+
* it from the declaration or from fountain's own record without a guess.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { Agent, Environment, Vault } from "../generated/index";
|
|
47
|
+
|
|
48
|
+
/** The metadata key a box's served port is recorded under, on its Environment and Agent. */
|
|
49
|
+
export const BOX_PORT_METADATA_KEY = "box-port";
|
|
50
|
+
|
|
51
|
+
/** The port a box serves when none is given: the door's. */
|
|
52
|
+
export const BOX_DEFAULT_PORT = 8080;
|
|
53
|
+
|
|
54
|
+
/** Where the app repo is cloned when no `mountPath` is given. */
|
|
55
|
+
export const BOX_DEFAULT_MOUNT_PATH = "/workspace/app";
|
|
56
|
+
|
|
57
|
+
/** A permission verdict, or a number of seconds under the `ask_timeout` key. */
|
|
58
|
+
export type BoxPermissionPolicy = Record<string, "ask" | "auto_allow" | "auto_deny" | number>;
|
|
59
|
+
|
|
60
|
+
/** The app repo the box serves. Shape of fountain's `Repository`. */
|
|
61
|
+
export interface BoxRepositoryOpts {
|
|
62
|
+
/** https clone url. */
|
|
63
|
+
url: string;
|
|
64
|
+
/** Absolute path the repo is cloned to. Default `/workspace/app`. */
|
|
65
|
+
mountPath?: string;
|
|
66
|
+
/** Branch or tag. The default branch when omitted. */
|
|
67
|
+
ref?: string;
|
|
68
|
+
/** Name of the secret holding a clone token. Required for a private repo. */
|
|
69
|
+
secretKey?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The secrets every box of this kind shares, held in a Vault of the box's own. */
|
|
73
|
+
export interface BoxVaultOpts {
|
|
74
|
+
/** Vault name. Default `<name>-secrets`. */
|
|
75
|
+
name?: string;
|
|
76
|
+
description?: string;
|
|
77
|
+
/** Written at apply. A reference that resolves at build, never a literal (FTN001). */
|
|
78
|
+
secrets?: { key: string; value: string }[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface BoxOpts {
|
|
82
|
+
/** The Agent's name. The Environment is `<name>-env`. */
|
|
83
|
+
name: string;
|
|
84
|
+
/** The app repo the box serves, cloned into the sandbox before setup runs. */
|
|
85
|
+
repo: BoxRepositoryOpts;
|
|
86
|
+
/** The provisioning script's text, run as the Environment's `setup_script`. */
|
|
87
|
+
setupScript: string;
|
|
88
|
+
/** Setup exec timeout, 1 to 900 seconds (FTN024). fountain's default is 120. */
|
|
89
|
+
setupTimeoutSeconds?: number;
|
|
90
|
+
/** Agent runtime. Default `claude`. */
|
|
91
|
+
runtime?: "claude" | "codex" | "gemini" | "opencode";
|
|
92
|
+
/** Canonical provider/model_id. Omitted, fountain's default for the runtime. */
|
|
93
|
+
model?: string;
|
|
94
|
+
/** Per-tool permission policy. Required: fountain's unset policy is `auto_allow`. */
|
|
95
|
+
permissionPolicy: BoxPermissionPolicy;
|
|
96
|
+
/** Environment packages, passed through. */
|
|
97
|
+
packages?: Record<string, unknown>;
|
|
98
|
+
/** Environment variables, passed through. Not for secrets: use `vault`. */
|
|
99
|
+
envVars?: Record<string, string>;
|
|
100
|
+
/** Egress allowlist under `limited` networking. Default [], deny-all. */
|
|
101
|
+
allowedHosts?: string[];
|
|
102
|
+
/** Open the sandbox's network. FTN011 warns on it. Refused with `allowedHosts`. */
|
|
103
|
+
unrestrictedNetworking?: boolean;
|
|
104
|
+
/** Declare a Vault for the box's shared secrets. */
|
|
105
|
+
vault?: BoxVaultOpts;
|
|
106
|
+
/**
|
|
107
|
+
* Vaults a conversation may attach. Default: the box's own vault, or none.
|
|
108
|
+
* `"any"` leaves the list unset, which fountain reads as any vault the
|
|
109
|
+
* tenant owns: what a runtime that makes a vault per box (hud's) needs.
|
|
110
|
+
*/
|
|
111
|
+
allowedVaults?: "any" | Array<InstanceType<typeof Vault> | string>;
|
|
112
|
+
/** The port the box serves. Default 8080, the door's. Recorded as `box-port` metadata. */
|
|
113
|
+
port?: number;
|
|
114
|
+
system?: string;
|
|
115
|
+
skills?: Array<Record<string, unknown>>;
|
|
116
|
+
mcpServers?: Record<string, unknown>;
|
|
117
|
+
/** Extra metadata, merged over the ownership marker on every resource the box declares. */
|
|
118
|
+
metadata?: Record<string, unknown>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface BoxResources {
|
|
122
|
+
environment: InstanceType<typeof Environment>;
|
|
123
|
+
agent: InstanceType<typeof Agent>;
|
|
124
|
+
vault?: InstanceType<typeof Vault>;
|
|
125
|
+
/** The served port, as recorded under `box-port`. */
|
|
126
|
+
port: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function Box(opts: BoxOpts): BoxResources {
|
|
130
|
+
const port = opts.port ?? BOX_DEFAULT_PORT;
|
|
131
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
132
|
+
throw new Error(`Box "${opts.name}": port ${port} is not a TCP port (1 to 65535)`);
|
|
133
|
+
}
|
|
134
|
+
if (opts.unrestrictedNetworking && opts.allowedHosts !== undefined) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Box "${opts.name}": allowedHosts and unrestrictedNetworking together — ` +
|
|
137
|
+
`an allowlist means nothing on an open network. Pass one.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (!opts.setupScript.trim()) {
|
|
141
|
+
throw new Error(`Box "${opts.name}": setupScript is empty — a box is provisioned by its setup script`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const owned = { "managed-by": "chant", ...(opts.metadata ?? {}) };
|
|
145
|
+
const metadata = { ...owned, [BOX_PORT_METADATA_KEY]: port };
|
|
146
|
+
|
|
147
|
+
const repository = {
|
|
148
|
+
url: opts.repo.url,
|
|
149
|
+
mount_path: opts.repo.mountPath ?? BOX_DEFAULT_MOUNT_PATH,
|
|
150
|
+
...(opts.repo.ref !== undefined ? { ref: opts.repo.ref } : {}),
|
|
151
|
+
...(opts.repo.secretKey !== undefined ? { secret_key: opts.repo.secretKey } : {}),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const environment = new Environment({
|
|
155
|
+
name: `${opts.name}-env`,
|
|
156
|
+
repositories: [repository],
|
|
157
|
+
setup_script: opts.setupScript,
|
|
158
|
+
...(opts.setupTimeoutSeconds !== undefined ? { setup_timeout_seconds: opts.setupTimeoutSeconds } : {}),
|
|
159
|
+
...(opts.packages ? { packages: opts.packages } : {}),
|
|
160
|
+
...(opts.envVars ? { env_vars: opts.envVars } : {}),
|
|
161
|
+
...(opts.unrestrictedNetworking
|
|
162
|
+
? { networking_type: "unrestricted" as const }
|
|
163
|
+
: { networking_type: "limited" as const, networking_config: { allowed_hosts: opts.allowedHosts ?? [] } }),
|
|
164
|
+
metadata,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const vault = opts.vault
|
|
168
|
+
? new Vault({
|
|
169
|
+
name: opts.vault.name ?? `${opts.name}-secrets`,
|
|
170
|
+
...(opts.vault.description !== undefined ? { description: opts.vault.description } : {}),
|
|
171
|
+
...(opts.vault.secrets ? { secrets: opts.vault.secrets } : {}),
|
|
172
|
+
metadata: owned,
|
|
173
|
+
})
|
|
174
|
+
: undefined;
|
|
175
|
+
|
|
176
|
+
// A Vault declaration rather than its `.id`, as in Steward: the manifest's
|
|
177
|
+
// reference form is the resource's name (FTN021), and an AttrRef would
|
|
178
|
+
// serialize to the chant export name instead.
|
|
179
|
+
const allowedVaults =
|
|
180
|
+
opts.allowedVaults === "any" ? undefined : (opts.allowedVaults ?? (vault ? [vault] : []));
|
|
181
|
+
|
|
182
|
+
const agent = new Agent({
|
|
183
|
+
name: opts.name,
|
|
184
|
+
runtime: opts.runtime ?? "claude",
|
|
185
|
+
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
186
|
+
sandbox_mode: "persistent",
|
|
187
|
+
environment,
|
|
188
|
+
permission_policy: opts.permissionPolicy,
|
|
189
|
+
...(allowedVaults !== undefined ? { allowed_vault_ids: allowedVaults } : {}),
|
|
190
|
+
...(opts.skills ? { skills: opts.skills } : {}),
|
|
191
|
+
...(opts.mcpServers ? { mcp_servers: opts.mcpServers } : {}),
|
|
192
|
+
...(opts.system !== undefined ? { system: opts.system } : {}),
|
|
193
|
+
metadata,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return { environment, agent, ...(vault ? { vault } : {}), port };
|
|
197
|
+
}
|
|
@@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it } from "vitest";
|
|
|
2
2
|
import { WatchOp, type OpConfig } from "@intentius/chant/op";
|
|
3
3
|
import { ConciergeStack } from "./concierge-stack";
|
|
4
4
|
import { Steward, stewardForOp, __resetStewardsForTests } from "./steward";
|
|
5
|
+
import { Box, BOX_PORT_METADATA_KEY } from "./box";
|
|
6
|
+
import { postSynthChecks } from "../lint/post-synth/index";
|
|
7
|
+
import spec from "../spec/fountain-openapi.snapshot.json";
|
|
5
8
|
import { Environment, Vault } from "../generated/index";
|
|
6
9
|
import { fountainSerializer } from "../serializer";
|
|
7
10
|
import type { Declarable } from "@intentius/chant";
|
|
@@ -234,3 +237,133 @@ describe("Steward", () => {
|
|
|
234
237
|
expect(yaml).toMatch(/allowed_vault_ids:\n\s+- prod-creds/);
|
|
235
238
|
});
|
|
236
239
|
});
|
|
240
|
+
|
|
241
|
+
// ── Box ───────────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
describe("Box", () => {
|
|
244
|
+
const base = {
|
|
245
|
+
name: "studio-box",
|
|
246
|
+
repo: { url: "https://github.com/arugula-salad/studio" },
|
|
247
|
+
setupScript: "#!/bin/bash\nexec ~/box/provision-template.sh\n",
|
|
248
|
+
permissionPolicy: { default: "auto_allow" as const },
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
it("declares a closed Environment and a persistent claude Agent, with the port in metadata", () => {
|
|
252
|
+
const { environment, agent, vault, port } = Box(base);
|
|
253
|
+
|
|
254
|
+
const env = props(environment);
|
|
255
|
+
expect(env.name).toBe("studio-box-env");
|
|
256
|
+
expect(env.repositories).toEqual([{ url: "https://github.com/arugula-salad/studio", mount_path: "/workspace/app" }]);
|
|
257
|
+
expect(env.setup_script).toBe(base.setupScript);
|
|
258
|
+
expect(env.networking_type).toBe("limited");
|
|
259
|
+
expect(env.networking_config).toEqual({ allowed_hosts: [] });
|
|
260
|
+
expect(env.metadata).toEqual({ "managed-by": "chant", [BOX_PORT_METADATA_KEY]: 8080 });
|
|
261
|
+
|
|
262
|
+
const a = props(agent);
|
|
263
|
+
expect(a.name).toBe("studio-box");
|
|
264
|
+
expect(a.runtime).toBe("claude");
|
|
265
|
+
expect(a.model).toBeUndefined();
|
|
266
|
+
expect(a.sandbox_mode).toBe("persistent");
|
|
267
|
+
expect(a.environment).toBe(environment);
|
|
268
|
+
expect(a.permission_policy).toEqual({ default: "auto_allow" });
|
|
269
|
+
expect(a.allowed_vault_ids).toEqual([]);
|
|
270
|
+
expect(a.metadata).toEqual({ "managed-by": "chant", "box-port": 8080 });
|
|
271
|
+
|
|
272
|
+
expect(vault).toBeUndefined();
|
|
273
|
+
expect(port).toBe(8080);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("declares the shared vault and scopes the agent to it", () => {
|
|
277
|
+
const { agent, vault } = Box({
|
|
278
|
+
...base,
|
|
279
|
+
vault: { secrets: [{ key: "STUDIO_SECRET", value: "${STUDIO_SECRET}" }] },
|
|
280
|
+
});
|
|
281
|
+
expect(props(vault).name).toBe("studio-box-secrets");
|
|
282
|
+
expect(props(vault).secrets).toEqual([{ key: "STUDIO_SECRET", value: "${STUDIO_SECRET}" }]);
|
|
283
|
+
expect(props(vault).metadata).toEqual({ "managed-by": "chant" });
|
|
284
|
+
expect(props(agent).allowed_vault_ids).toEqual([vault]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("loosening is a visible parameter", () => {
|
|
288
|
+
const open = Box({
|
|
289
|
+
...base,
|
|
290
|
+
runtime: "codex",
|
|
291
|
+
model: "openai/gpt-5",
|
|
292
|
+
port: 3000,
|
|
293
|
+
unrestrictedNetworking: true,
|
|
294
|
+
allowedVaults: "any",
|
|
295
|
+
repo: { url: "https://github.com/o/app", mountPath: "/srv/app", ref: "v1", secretKey: "GH_TOKEN" },
|
|
296
|
+
envVars: { NODE_ENV: "production" },
|
|
297
|
+
packages: { apt: ["podman"] },
|
|
298
|
+
setupTimeoutSeconds: 900,
|
|
299
|
+
metadata: { team: "studio" },
|
|
300
|
+
});
|
|
301
|
+
const env = props(open.environment);
|
|
302
|
+
expect(env.networking_type).toBe("unrestricted");
|
|
303
|
+
expect(env.networking_config).toBeUndefined();
|
|
304
|
+
expect(env.repositories).toEqual([{ url: "https://github.com/o/app", mount_path: "/srv/app", ref: "v1", secret_key: "GH_TOKEN" }]);
|
|
305
|
+
expect(env.env_vars).toEqual({ NODE_ENV: "production" });
|
|
306
|
+
expect(env.packages).toEqual({ apt: ["podman"] });
|
|
307
|
+
expect(env.setup_timeout_seconds).toBe(900);
|
|
308
|
+
expect(env.metadata).toEqual({ "managed-by": "chant", team: "studio", "box-port": 3000 });
|
|
309
|
+
const a = props(open.agent);
|
|
310
|
+
expect(a.runtime).toBe("codex");
|
|
311
|
+
expect(a.model).toBe("openai/gpt-5");
|
|
312
|
+
// "any" leaves the list unset: fountain reads null as any vault the tenant owns.
|
|
313
|
+
expect("allowed_vault_ids" in a).toBe(false);
|
|
314
|
+
expect(open.port).toBe(3000);
|
|
315
|
+
|
|
316
|
+
const listed = Box({ ...base, allowedHosts: ["registry.npmjs.org"] });
|
|
317
|
+
expect(props(listed.environment).networking_config).toEqual({ allowed_hosts: ["registry.npmjs.org"] });
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("refuses what it cannot mean", () => {
|
|
321
|
+
expect(() => Box({ ...base, port: 0 })).toThrow(/not a TCP port/);
|
|
322
|
+
expect(() => Box({ ...base, port: 8080.5 })).toThrow(/not a TCP port/);
|
|
323
|
+
expect(() => Box({ ...base, unrestrictedNetworking: true, allowedHosts: ["github.com"] })).toThrow(
|
|
324
|
+
/allowedHosts and unrestrictedNetworking/,
|
|
325
|
+
);
|
|
326
|
+
expect(() => Box({ ...base, setupScript: " " })).toThrow(/setupScript is empty/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("serializes to a manifest whose specs the pinned API accepts, clean under every post-synth check", () => {
|
|
330
|
+
const { environment, agent, vault } = Box({
|
|
331
|
+
...base,
|
|
332
|
+
allowedHosts: ["registry.npmjs.org", "github.com"],
|
|
333
|
+
vault: { secrets: [{ key: "STUDIO_SECRET", value: "${STUDIO_SECRET}" }] },
|
|
334
|
+
});
|
|
335
|
+
const entities = new Map<string, Declarable>([
|
|
336
|
+
["boxAgent", agent as unknown as Declarable],
|
|
337
|
+
["boxVault", vault as unknown as Declarable],
|
|
338
|
+
["boxEnv", environment as unknown as Declarable],
|
|
339
|
+
]);
|
|
340
|
+
|
|
341
|
+
const yaml = fountainSerializer.serialize(entities) as string;
|
|
342
|
+
expect([...yaml.matchAll(/^kind: (\w+)$/gm)].map((m) => m[1])).toEqual(["Environment", "Vault", "Agent"]);
|
|
343
|
+
expect(yaml).toContain("environment: studio-box-env");
|
|
344
|
+
expect(yaml).toMatch(/allowed_vault_ids:\n\s+- studio-box-secrets/);
|
|
345
|
+
expect(yaml).toContain("box-port: 8080");
|
|
346
|
+
|
|
347
|
+
// Every spec key is a field of the pinned create request, or one of the
|
|
348
|
+
// two manifest-level forms fountainApply and `fountain apply -f` resolve:
|
|
349
|
+
// a name reference to the agent's environment, and inline secrets.
|
|
350
|
+
const schemas = (spec as unknown as { components: { schemas: Record<string, { properties: Record<string, unknown> }> } })
|
|
351
|
+
.components.schemas;
|
|
352
|
+
const manifestOnly = new Set(["environment", "secrets"]);
|
|
353
|
+
for (const [kind, entity] of [
|
|
354
|
+
["EnvironmentRequest", environment],
|
|
355
|
+
["VaultRequest", vault],
|
|
356
|
+
["AgentRequest", agent],
|
|
357
|
+
] as const) {
|
|
358
|
+
const fields = Object.keys(schemas[kind].properties);
|
|
359
|
+
for (const key of Object.keys(props(entity))) {
|
|
360
|
+
if (key === "name" || manifestOnly.has(key)) continue;
|
|
361
|
+
expect(fields, `${kind} has no field ${key}`).toContain(key);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const ctx = { outputs: new Map(), entities, buildResult: { warnings: [], errors: [] } } as unknown as PostSynthContext;
|
|
366
|
+
const diagnostics = postSynthChecks.flatMap((c) => c.check(ctx));
|
|
367
|
+
expect(diagnostics).toEqual([]);
|
|
368
|
+
});
|
|
369
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,8 @@ export { ConciergeStack } from "./composites/concierge-stack";
|
|
|
41
41
|
export type { ConciergeStackOpts, ConciergeStackResources } from "./composites/concierge-stack";
|
|
42
42
|
export { Steward, stewardForOp, STEWARD_RUNTIME_COMMAND } from "./composites/steward";
|
|
43
43
|
export type { StewardOp, StewardOpts, StewardResources, StewardWebhookOpts } from "./composites/steward";
|
|
44
|
+
export { Box, BOX_PORT_METADATA_KEY, BOX_DEFAULT_PORT, BOX_DEFAULT_MOUNT_PATH } from "./composites/box";
|
|
45
|
+
export type { BoxOpts, BoxResources, BoxRepositoryOpts, BoxVaultOpts, BoxPermissionPolicy } from "./composites/box";
|
|
44
46
|
|
|
45
47
|
// `chant acp` (#2125) — the ACP server, mounted through the plugin's command
|
|
46
48
|
// group. Exported so an embedder can serve it over its own transport.
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
vaultNameRefs,
|
|
10
10
|
type FountainHttp,
|
|
11
11
|
} from "./fountain-apply";
|
|
12
|
-
import { fountainRun } from "./fountain-run";
|
|
13
12
|
import type { ChantConfig } from "@intentius/chant/config";
|
|
14
13
|
|
|
15
14
|
interface Call {
|
|
@@ -904,56 +903,3 @@ describe("fountainApply — Teammate, Schedule and Webhook", () => {
|
|
|
904
903
|
]);
|
|
905
904
|
});
|
|
906
905
|
});
|
|
907
|
-
|
|
908
|
-
describe("fountainRun", () => {
|
|
909
|
-
it("resolves the agent by name, starts, and polls to a terminal status", async () => {
|
|
910
|
-
let polls = 0;
|
|
911
|
-
const http: FountainHttp = async (method, path, body) => {
|
|
912
|
-
if (path.startsWith("/api/agents?search=")) {
|
|
913
|
-
return { status: 200, json: { data: [{ id: "agent-1", name: "researcher" }] } };
|
|
914
|
-
}
|
|
915
|
-
if (method === "POST" && path === "/api/conversations") {
|
|
916
|
-
expect((body as Record<string, unknown>).agent_id).toBe("agent-1");
|
|
917
|
-
return { status: 201, json: { data: { id: "conv-1" } } };
|
|
918
|
-
}
|
|
919
|
-
if (method === "GET" && path === "/api/conversations/conv-1") {
|
|
920
|
-
polls += 1;
|
|
921
|
-
return {
|
|
922
|
-
status: 200,
|
|
923
|
-
json: { data: { status: polls < 3 ? "running" : "completed" } },
|
|
924
|
-
};
|
|
925
|
-
}
|
|
926
|
-
throw new Error(`unrouted: ${method} ${path}`);
|
|
927
|
-
};
|
|
928
|
-
|
|
929
|
-
const result = await fountainRun(
|
|
930
|
-
{ agent: "researcher", prompt: "hi", pollMs: 1, sleep: async () => {} },
|
|
931
|
-
http,
|
|
932
|
-
);
|
|
933
|
-
expect(result).toEqual({ conversationId: "conv-1", status: "completed", terminatedByDeadline: false });
|
|
934
|
-
});
|
|
935
|
-
|
|
936
|
-
it("terminates the conversation when the deadline passes", async () => {
|
|
937
|
-
const calls: string[] = [];
|
|
938
|
-
const http: FountainHttp = async (method, path) => {
|
|
939
|
-
calls.push(`${method} ${path}`);
|
|
940
|
-
if (path === "/api/conversations" && method === "POST") {
|
|
941
|
-
return { status: 201, json: { data: { id: "conv-2" } } };
|
|
942
|
-
}
|
|
943
|
-
if (method === "GET") return { status: 200, json: { data: { status: "running" } } };
|
|
944
|
-
return { status: 200, json: null };
|
|
945
|
-
};
|
|
946
|
-
|
|
947
|
-
const result = await fountainRun(
|
|
948
|
-
{
|
|
949
|
-
agent: "123e4567-e89b-42d3-a456-426614174000",
|
|
950
|
-
timeoutMs: 1,
|
|
951
|
-
pollMs: 1,
|
|
952
|
-
sleep: async () => {},
|
|
953
|
-
},
|
|
954
|
-
http,
|
|
955
|
-
);
|
|
956
|
-
expect(result.terminatedByDeadline).toBe(true);
|
|
957
|
-
expect(calls).toContain("POST /api/conversations/conv-2/terminate");
|
|
958
|
-
});
|
|
959
|
-
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { fountainRun, resolveAgent, resolveAgentId } from "./fountain-run";
|
|
3
|
+
import type { FountainHttp } from "./fountain-apply";
|
|
4
|
+
|
|
5
|
+
describe("resolveAgent", () => {
|
|
6
|
+
it("resolves by name and reports sandbox_mode from the search result", async () => {
|
|
7
|
+
const http: FountainHttp = async (method, path) => {
|
|
8
|
+
if (method === "GET" && path.startsWith("/api/agents?search=")) {
|
|
9
|
+
return {
|
|
10
|
+
status: 200,
|
|
11
|
+
json: { data: [{ id: "agent-1", name: "researcher", sandbox_mode: "persistent" }] },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
throw new Error(`unrouted: ${method} ${path}`);
|
|
15
|
+
};
|
|
16
|
+
expect(await resolveAgent(http, "researcher")).toEqual({
|
|
17
|
+
id: "agent-1",
|
|
18
|
+
sandboxMode: "persistent",
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("defaults sandbox_mode to ephemeral when the agent omits it", async () => {
|
|
23
|
+
const http: FountainHttp = async () => ({
|
|
24
|
+
status: 200,
|
|
25
|
+
json: { data: [{ id: "agent-1", name: "researcher" }] },
|
|
26
|
+
});
|
|
27
|
+
expect(await resolveAgent(http, "researcher")).toEqual({
|
|
28
|
+
id: "agent-1",
|
|
29
|
+
sandboxMode: "ephemeral",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("looks a raw agent id up by GET /api/agents/{id} for its sandbox_mode", async () => {
|
|
34
|
+
const calls: string[] = [];
|
|
35
|
+
const http: FountainHttp = async (method, path) => {
|
|
36
|
+
calls.push(`${method} ${path}`);
|
|
37
|
+
return { status: 200, json: { data: { id: "123e4567-e89b-42d3-a456-426614174000", sandbox_mode: "persistent" } } };
|
|
38
|
+
};
|
|
39
|
+
const resolved = await resolveAgent(http, "123e4567-e89b-42d3-a456-426614174000");
|
|
40
|
+
expect(resolved).toEqual({ id: "123e4567-e89b-42d3-a456-426614174000", sandboxMode: "persistent" });
|
|
41
|
+
expect(calls).toEqual(["GET /api/agents/123e4567-e89b-42d3-a456-426614174000"]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("resolveAgentId", () => {
|
|
46
|
+
it("returns a raw agent id with no HTTP call", async () => {
|
|
47
|
+
const http: FountainHttp = async () => {
|
|
48
|
+
throw new Error("should not be called");
|
|
49
|
+
};
|
|
50
|
+
expect(await resolveAgentId(http, "123e4567-e89b-42d3-a456-426614174000")).toBe(
|
|
51
|
+
"123e4567-e89b-42d3-a456-426614174000",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** A scripted conversation: agent lookup, create, then a fixed sequence of GET replies. */
|
|
57
|
+
function scriptedConversation(opts: {
|
|
58
|
+
sandboxMode?: string;
|
|
59
|
+
conversationId: string;
|
|
60
|
+
statuses: string[];
|
|
61
|
+
turns?: Array<{ turn_number: number; status: string }>;
|
|
62
|
+
}) {
|
|
63
|
+
const calls: string[] = [];
|
|
64
|
+
let statusIndex = 0;
|
|
65
|
+
const http: FountainHttp = async (method, path, body) => {
|
|
66
|
+
calls.push(`${method} ${path}`);
|
|
67
|
+
if (method === "GET" && path.startsWith("/api/agents?search=")) {
|
|
68
|
+
return {
|
|
69
|
+
status: 200,
|
|
70
|
+
json: {
|
|
71
|
+
data: [{ id: "agent-1", name: "researcher", sandbox_mode: opts.sandboxMode }],
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (method === "POST" && path === "/api/conversations") {
|
|
76
|
+
expect((body as Record<string, unknown>).agent_id).toBe("agent-1");
|
|
77
|
+
return { status: 201, json: { data: { id: opts.conversationId } } };
|
|
78
|
+
}
|
|
79
|
+
if (method === "GET" && path === `/api/conversations/${opts.conversationId}`) {
|
|
80
|
+
const status = opts.statuses[Math.min(statusIndex, opts.statuses.length - 1)];
|
|
81
|
+
statusIndex += 1;
|
|
82
|
+
return { status: 200, json: { data: { status } } };
|
|
83
|
+
}
|
|
84
|
+
if (method === "GET" && path === `/api/conversations/${opts.conversationId}/turns`) {
|
|
85
|
+
return { status: 200, json: { data: opts.turns ?? [] } };
|
|
86
|
+
}
|
|
87
|
+
if (method === "POST" && path === `/api/conversations/${opts.conversationId}/terminate`) {
|
|
88
|
+
return { status: 200, json: null };
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`unrouted: ${method} ${path}`);
|
|
91
|
+
};
|
|
92
|
+
return { http, calls };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe("fountainRun — ephemeral (behaves as today, #2718)", () => {
|
|
96
|
+
it("resolves the agent by name, starts, and polls to a terminal status", async () => {
|
|
97
|
+
const { http } = scriptedConversation({
|
|
98
|
+
sandboxMode: "ephemeral",
|
|
99
|
+
conversationId: "conv-1",
|
|
100
|
+
statuses: ["running", "running", "completed"],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const result = await fountainRun(
|
|
104
|
+
{ agent: "researcher", prompt: "hi", pollMs: 1, sleep: async () => {} },
|
|
105
|
+
http,
|
|
106
|
+
);
|
|
107
|
+
expect(result).toEqual({
|
|
108
|
+
conversationId: "conv-1",
|
|
109
|
+
status: "completed",
|
|
110
|
+
persistent: false,
|
|
111
|
+
terminatedByDeadline: false,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("terminates the conversation when the deadline passes", async () => {
|
|
116
|
+
const { http, calls } = scriptedConversation({
|
|
117
|
+
sandboxMode: "ephemeral",
|
|
118
|
+
conversationId: "conv-2",
|
|
119
|
+
statuses: ["running"],
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const result = await fountainRun(
|
|
123
|
+
{ agent: "researcher", timeoutMs: 1, pollMs: 1, sleep: async () => {} },
|
|
124
|
+
http,
|
|
125
|
+
);
|
|
126
|
+
expect(result).toEqual({
|
|
127
|
+
conversationId: "conv-2",
|
|
128
|
+
status: "terminated",
|
|
129
|
+
persistent: false,
|
|
130
|
+
terminatedByDeadline: true,
|
|
131
|
+
});
|
|
132
|
+
expect(calls).toContain("POST /api/conversations/conv-2/terminate");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("fountainRun — persistent (#2718)", () => {
|
|
137
|
+
it("returns the finished turn's outcome once the conversation goes idle, without terminating", async () => {
|
|
138
|
+
const { http, calls } = scriptedConversation({
|
|
139
|
+
sandboxMode: "persistent",
|
|
140
|
+
conversationId: "conv-3",
|
|
141
|
+
statuses: ["pending", "running", "idle"],
|
|
142
|
+
turns: [{ turn_number: 1, status: "completed" }],
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const result = await fountainRun(
|
|
146
|
+
{ agent: "researcher", prompt: "hi", pollMs: 1, sleep: async () => {} },
|
|
147
|
+
http,
|
|
148
|
+
);
|
|
149
|
+
expect(result).toEqual({
|
|
150
|
+
conversationId: "conv-3",
|
|
151
|
+
status: "completed",
|
|
152
|
+
persistent: true,
|
|
153
|
+
terminatedByDeadline: false,
|
|
154
|
+
});
|
|
155
|
+
expect(calls).not.toContain("POST /api/conversations/conv-3/terminate");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("reports a failed turn's outcome", async () => {
|
|
159
|
+
const { http } = scriptedConversation({
|
|
160
|
+
sandboxMode: "persistent",
|
|
161
|
+
conversationId: "conv-4",
|
|
162
|
+
statuses: ["running", "idle"],
|
|
163
|
+
turns: [
|
|
164
|
+
{ turn_number: 1, status: "completed" },
|
|
165
|
+
{ turn_number: 2, status: "failed" },
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const result = await fountainRun({ agent: "researcher", pollMs: 1, sleep: async () => {} }, http);
|
|
170
|
+
expect(result.status).toBe("failed");
|
|
171
|
+
expect(result.persistent).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("does not terminate a hung run by default — the machine is a home (terminate: never)", async () => {
|
|
175
|
+
const { http, calls } = scriptedConversation({
|
|
176
|
+
sandboxMode: "persistent",
|
|
177
|
+
conversationId: "conv-5",
|
|
178
|
+
statuses: ["running"],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const result = await fountainRun(
|
|
182
|
+
{ agent: "researcher", timeoutMs: 1, pollMs: 1, sleep: async () => {} },
|
|
183
|
+
http,
|
|
184
|
+
);
|
|
185
|
+
expect(result.terminatedByDeadline).toBe(true);
|
|
186
|
+
expect(result.persistent).toBe(true);
|
|
187
|
+
expect(calls).not.toContain("POST /api/conversations/conv-5/terminate");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("terminate: on-deadline still ends a persistent agent's hung run", async () => {
|
|
191
|
+
const { http, calls } = scriptedConversation({
|
|
192
|
+
sandboxMode: "persistent",
|
|
193
|
+
conversationId: "conv-6",
|
|
194
|
+
statuses: ["running"],
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await fountainRun(
|
|
198
|
+
{ agent: "researcher", terminate: "on-deadline", timeoutMs: 1, pollMs: 1, sleep: async () => {} },
|
|
199
|
+
http,
|
|
200
|
+
);
|
|
201
|
+
expect(calls).toContain("POST /api/conversations/conv-6/terminate");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("terminate: always ends the machine even after a clean turn", async () => {
|
|
205
|
+
const { http, calls } = scriptedConversation({
|
|
206
|
+
sandboxMode: "persistent",
|
|
207
|
+
conversationId: "conv-7",
|
|
208
|
+
statuses: ["running", "idle"],
|
|
209
|
+
turns: [{ turn_number: 1, status: "completed" }],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const result = await fountainRun(
|
|
213
|
+
{ agent: "researcher", terminate: "always", pollMs: 1, sleep: async () => {} },
|
|
214
|
+
http,
|
|
215
|
+
);
|
|
216
|
+
expect(result.terminatedByDeadline).toBe(false);
|
|
217
|
+
expect(calls).toContain("POST /api/conversations/conv-7/terminate");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* fountainRun — start a conversation from a declared Agent and follow it
|
|
3
|
-
* to
|
|
3
|
+
* to the end of its turn.
|
|
4
4
|
*
|
|
5
5
|
* Conversations are runs, not declarables (the one fountain kind the
|
|
6
6
|
* lexicon deliberately does not model as a resource). This op is the
|
|
7
|
-
* imperative half: resolve the agent by name, POST the conversation,
|
|
8
|
-
* poll until
|
|
9
|
-
*
|
|
7
|
+
* imperative half: resolve the agent by name, POST the conversation, and
|
|
8
|
+
* poll until the turn is done.
|
|
9
|
+
*
|
|
10
|
+
* What "done" means depends on the agent's `sandbox_mode` (#2718). An
|
|
11
|
+
* `ephemeral` conversation's machine is its own and this polls the
|
|
12
|
+
* conversation's own status to `completed | failed | timed_out |
|
|
13
|
+
* terminated` — none of which a running server's conversation status
|
|
14
|
+
* actually reaches on its own, so in practice this waits out `timeoutMs`
|
|
15
|
+
* and terminates, as it always has. A `persistent` agent's conversation
|
|
16
|
+
* (`Steward`, and `Box` from #2705) shares one machine across every
|
|
17
|
+
* conversation on it, and a turn ending there does not tear the sandbox
|
|
18
|
+
* down: the conversation settles on `idle`, not on any of the four
|
|
19
|
+
* statuses above. Waiting for one of those on a persistent agent is the
|
|
20
|
+
* bug this op used to have — it waited out the whole deadline after the
|
|
21
|
+
* turn had already finished, then terminated a machine that was meant to
|
|
22
|
+
* persist. For `idle`, this instead reads the finished turn back
|
|
23
|
+
* (`GET .../turns`) and returns its own outcome (`completed | failed |
|
|
24
|
+
* interrupted` — fountain's `end_turn` block, or an error) without
|
|
25
|
+
* terminating anything.
|
|
26
|
+
*
|
|
27
|
+
* Terminating the conversation's machine on deadline (a hung run should
|
|
28
|
+
* not outlive the op that started it) still happens whenever the turn has
|
|
29
|
+
* not ended — for either sandbox mode. `terminate` makes the choice
|
|
30
|
+
* explicit rather than leaving it implicit in `sandbox_mode`.
|
|
10
31
|
*/
|
|
11
32
|
|
|
12
33
|
import {
|
|
@@ -16,8 +37,39 @@ import {
|
|
|
16
37
|
type FountainConnectionDeps,
|
|
17
38
|
} from "./fountain-apply";
|
|
18
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Conversation statuses that end an *ephemeral* agent's run. `completed`
|
|
42
|
+
* and `timed_out` are not values fountain's `Conversation.status` takes
|
|
43
|
+
* today (its enum is `pending | running | idle | failed | terminated`) —
|
|
44
|
+
* they are kept so a server that starts reporting them is handled without
|
|
45
|
+
* a further change here.
|
|
46
|
+
*/
|
|
19
47
|
export const TERMINAL_STATUSES = new Set(["completed", "failed", "timed_out", "terminated"]);
|
|
20
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Conversation statuses that end a *persistent* agent's run: everything
|
|
51
|
+
* {@link TERMINAL_STATUSES} does, plus `idle` — the status a persistent
|
|
52
|
+
* conversation settles on once its turn ends, machine still up (#2718).
|
|
53
|
+
*/
|
|
54
|
+
export const PERSISTENT_DONE_STATUSES = new Set([...TERMINAL_STATUSES, "idle"]);
|
|
55
|
+
|
|
56
|
+
/** Turn statuses that mean the turn has ended — fountain's `end_turn` block, or an error. */
|
|
57
|
+
const TERMINAL_TURN_STATUSES = new Set(["completed", "failed", "interrupted"]);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* How `fountainRun` treats the conversation's machine once its wait ends
|
|
61
|
+
* (#2718).
|
|
62
|
+
*
|
|
63
|
+
* - `on-deadline` — terminate only when `timeoutMs` is hit with the turn
|
|
64
|
+
* still unfinished, never on a turn that ended cleanly. This was
|
|
65
|
+
* `fountainRun`'s only behavior before this option existed.
|
|
66
|
+
* - `never` — never terminate, even past the deadline; the caller, or
|
|
67
|
+
* fountain's own idle/max-lifetime bounds, is responsible for the machine.
|
|
68
|
+
* - `always` — terminate whenever the wait ends, whether the turn ended
|
|
69
|
+
* cleanly or the deadline fired.
|
|
70
|
+
*/
|
|
71
|
+
export type TerminatePolicy = "never" | "on-deadline" | "always";
|
|
72
|
+
|
|
21
73
|
export interface FountainRunArgs {
|
|
22
74
|
/** Agent name (resolved against /api/agents) or a raw agent id. */
|
|
23
75
|
agent: string;
|
|
@@ -34,31 +86,80 @@ export interface FountainRunArgs {
|
|
|
34
86
|
profile?: string;
|
|
35
87
|
/** Project root `chant.config.ts` is read from. Default: process.cwd(). */
|
|
36
88
|
cwd?: string;
|
|
37
|
-
/** Give up
|
|
89
|
+
/** Give up after this long. Default 10 min. */
|
|
38
90
|
timeoutMs?: number;
|
|
39
91
|
/** Poll interval. Default 5s. */
|
|
40
92
|
pollMs?: number;
|
|
93
|
+
/**
|
|
94
|
+
* When to terminate the conversation's machine (#2718). Default follows
|
|
95
|
+
* the agent's `sandbox_mode`: `on-deadline` for `ephemeral` (unchanged),
|
|
96
|
+
* `never` for `persistent` — its machine is a home meant to outlive the
|
|
97
|
+
* conversation, so a run that hits its deadline leaves it be unless this
|
|
98
|
+
* says otherwise.
|
|
99
|
+
*/
|
|
100
|
+
terminate?: TerminatePolicy;
|
|
41
101
|
/** Injectable clock/sleep for tests. */
|
|
42
102
|
sleep?: (ms: number) => Promise<void>;
|
|
43
103
|
}
|
|
44
104
|
|
|
45
105
|
export interface FountainRunResult {
|
|
46
106
|
conversationId: string;
|
|
107
|
+
/**
|
|
108
|
+
* The run's outcome. For a persistent agent whose turn ended, this is the
|
|
109
|
+
* turn's own status (`completed | failed | interrupted`); otherwise it is
|
|
110
|
+
* the conversation's status when the wait ended (`terminated` on a
|
|
111
|
+
* deadline).
|
|
112
|
+
*/
|
|
47
113
|
status: string;
|
|
48
|
-
/** True when the
|
|
114
|
+
/** True when the agent's `sandbox_mode` resolved to `persistent`. */
|
|
115
|
+
persistent: boolean;
|
|
116
|
+
/** True when the op hit its deadline with the turn still unfinished. */
|
|
49
117
|
terminatedByDeadline: boolean;
|
|
50
118
|
}
|
|
51
119
|
|
|
52
120
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
53
121
|
|
|
54
|
-
export
|
|
55
|
-
|
|
122
|
+
export interface ResolvedAgent {
|
|
123
|
+
id: string;
|
|
124
|
+
/** `ephemeral` or `persistent`; fountain's own default is `ephemeral`. */
|
|
125
|
+
sandboxMode: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Resolve an agent by name or id, along with its `sandbox_mode`. */
|
|
129
|
+
export async function resolveAgent(http: FountainHttp, agent: string): Promise<ResolvedAgent> {
|
|
130
|
+
if (UUID_RE.test(agent)) {
|
|
131
|
+
const { status, json } = await http("GET", `/api/agents/${agent}`);
|
|
132
|
+
if (status !== 200) throw new Error(`fountainRun: agent lookup failed (${status})`);
|
|
133
|
+
const data = (json as { data?: { id?: string; sandbox_mode?: string } })?.data;
|
|
134
|
+
if (!data?.id) throw new Error(`fountainRun: no agent "${agent}"`);
|
|
135
|
+
return { id: data.id, sandboxMode: data.sandbox_mode ?? "ephemeral" };
|
|
136
|
+
}
|
|
56
137
|
const { status, json } = await http("GET", `/api/agents?search=${encodeURIComponent(agent)}`);
|
|
57
138
|
if (status !== 200) throw new Error(`fountainRun: agent lookup failed (${status})`);
|
|
58
|
-
const data =
|
|
139
|
+
const data =
|
|
140
|
+
(json as { data?: Array<{ id: string; name: string; sandbox_mode?: string }> })?.data ?? [];
|
|
59
141
|
const exact = data.find((a) => a.name === agent);
|
|
60
142
|
if (!exact) throw new Error(`fountainRun: no agent named "${agent}"`);
|
|
61
|
-
return exact.id;
|
|
143
|
+
return { id: exact.id, sandboxMode: exact.sandbox_mode ?? "ephemeral" };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Agent id only — the common case, and the one call sites outside this file use. */
|
|
147
|
+
export async function resolveAgentId(http: FountainHttp, agent: string): Promise<string> {
|
|
148
|
+
if (UUID_RE.test(agent)) return agent;
|
|
149
|
+
return (await resolveAgent(http, agent)).id;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The latest turn's status, or undefined if it can't be read or none exists. */
|
|
153
|
+
async function latestTurnStatus(
|
|
154
|
+
http: FountainHttp,
|
|
155
|
+
conversationId: string,
|
|
156
|
+
): Promise<string | undefined> {
|
|
157
|
+
const { status, json } = await http("GET", `/api/conversations/${conversationId}/turns`);
|
|
158
|
+
if (status !== 200) return undefined;
|
|
159
|
+
const turns = (json as { data?: Array<{ turn_number?: number; status?: string }> })?.data ?? [];
|
|
160
|
+
if (turns.length === 0) return undefined;
|
|
161
|
+
const latest = turns.reduce((a, b) => ((b.turn_number ?? 0) > (a.turn_number ?? 0) ? b : a));
|
|
162
|
+
return latest.status;
|
|
62
163
|
}
|
|
63
164
|
|
|
64
165
|
export async function fountainRun(
|
|
@@ -75,7 +176,10 @@ export async function fountainRun(
|
|
|
75
176
|
const timeoutMs = args.timeoutMs ?? 600_000;
|
|
76
177
|
const pollMs = args.pollMs ?? 5_000;
|
|
77
178
|
|
|
78
|
-
const agentId = await
|
|
179
|
+
const { id: agentId, sandboxMode } = await resolveAgent(client, args.agent);
|
|
180
|
+
const persistent = sandboxMode === "persistent";
|
|
181
|
+
const terminatePolicy: TerminatePolicy = args.terminate ?? (persistent ? "never" : "on-deadline");
|
|
182
|
+
const doneStatuses = persistent ? PERSISTENT_DONE_STATUSES : TERMINAL_STATUSES;
|
|
79
183
|
|
|
80
184
|
const createBody: Record<string, unknown> = { agent_id: agentId };
|
|
81
185
|
if (args.prompt !== undefined) createBody.prompt = args.prompt;
|
|
@@ -89,19 +193,33 @@ export async function fountainRun(
|
|
|
89
193
|
if (!conversationId) throw new Error("fountainRun: conversation create returned no id");
|
|
90
194
|
|
|
91
195
|
const deadline = Date.now() + timeoutMs;
|
|
92
|
-
let
|
|
196
|
+
let conversationStatus = "pending";
|
|
93
197
|
while (Date.now() < deadline) {
|
|
94
198
|
const res = await client("GET", `/api/conversations/${conversationId}`);
|
|
95
199
|
if (res.status === 200) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
200
|
+
conversationStatus =
|
|
201
|
+
(res.json as { data?: { status?: string } })?.data?.status ?? conversationStatus;
|
|
202
|
+
if (doneStatuses.has(conversationStatus)) {
|
|
203
|
+
let status = conversationStatus;
|
|
204
|
+
if (persistent && conversationStatus === "idle") {
|
|
205
|
+
const turnStatus = await latestTurnStatus(client, conversationId);
|
|
206
|
+
if (turnStatus && TERMINAL_TURN_STATUSES.has(turnStatus)) status = turnStatus;
|
|
207
|
+
}
|
|
208
|
+
if (terminatePolicy === "always") {
|
|
209
|
+
await client("POST", `/api/conversations/${conversationId}/terminate`);
|
|
210
|
+
}
|
|
211
|
+
return { conversationId, status, persistent, terminatedByDeadline: false };
|
|
99
212
|
}
|
|
100
213
|
}
|
|
101
214
|
await sleep(pollMs);
|
|
102
215
|
}
|
|
103
216
|
|
|
104
|
-
// Deadline:
|
|
105
|
-
|
|
106
|
-
|
|
217
|
+
// Deadline: the turn never ended (or, for an ephemeral conversation, its
|
|
218
|
+
// done-status is never observed on the wire — see TERMINAL_STATUSES).
|
|
219
|
+
// `never` leaves a hung run's machine alone; every other policy ends the
|
|
220
|
+
// conversation so it does not outlive the op.
|
|
221
|
+
if (terminatePolicy !== "never") {
|
|
222
|
+
await client("POST", `/api/conversations/${conversationId}/terminate`);
|
|
223
|
+
}
|
|
224
|
+
return { conversationId, status: "terminated", persistent, terminatedByDeadline: true };
|
|
107
225
|
}
|
|
@@ -25,5 +25,11 @@ export type {
|
|
|
25
25
|
FountainConnectionDeps,
|
|
26
26
|
} from "./fountain-apply";
|
|
27
27
|
|
|
28
|
-
export {
|
|
29
|
-
|
|
28
|
+
export {
|
|
29
|
+
fountainRun,
|
|
30
|
+
resolveAgent,
|
|
31
|
+
resolveAgentId,
|
|
32
|
+
TERMINAL_STATUSES,
|
|
33
|
+
PERSISTENT_DONE_STATUSES,
|
|
34
|
+
} from "./fountain-run";
|
|
35
|
+
export type { FountainRunArgs, FountainRunResult, ResolvedAgent, TerminatePolicy } from "./fountain-run";
|
|
@@ -31,4 +31,4 @@ The environment's config is the enforcement boundary, so watch it: `chant lifecy
|
|
|
31
31
|
|
|
32
32
|
## Running conversations
|
|
33
33
|
|
|
34
|
-
Conversations are runs, not resources. Use the `fountainRun` activity: resolves the agent by name, starts (optionally with a prompt and an allowlisted vault),
|
|
34
|
+
Conversations are runs, not resources. Use the `fountainRun` activity: resolves the agent by name, starts (optionally with a prompt and an allowlisted vault), and waits for the turn to end. What "end" means, and what happens to the machine, follows the agent's `sandbox_mode`: an `ephemeral` agent's conversation is polled for a terminal status and terminated on deadline, same as before; a `persistent` agent's (a `Steward` or `Box`) conversation settles on `idle` once its turn ends — machine still up for the next turn — so `fountainRun` reads the turn back and returns its outcome (`completed | failed | interrupted`) without terminating anything, by default. Pass `terminate: "on-deadline" | "always"` to opt a persistent agent's run back into ending the machine. Multi-turn interaction (follow-up prompts, interrupt) is fountain's own conversations API — keep chant to the lifecycle edges.
|