okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffauffix(extensions): activate bundles atomically with dependencies52bd519

main
13.0 KiB468 linesraw
1//// A broken extension must cost only itself. Albedo loads third-party
2//// extensions into the same session as its own, and a plugin that fails or
3//// raises used to take the whole session with it: no tools, no turn, no
4//// reply, and for a tool error a dead subagent. E2E cannot reach this,
5//// because the daemon only loads albedo's own registry, so the rule is
6//// checked here against deliberately broken extensions.
7
8import albedo/daemon/conversation
9import albedo/daemon/family
10import albedo/daemon/store
11import albedo/harness/extension
12import albedo/harness/extension/composition
13import albedo/harness/extensions
14import albedo/harness/runtime
15import albedo/harness/tool
16import albedo/openai_api/types
17import gleam/dynamic/decode
18import gleam/erlang/process
19import gleam/json
20import gleam/list
21import gleam/option.{None, Some}
22import gleam/string
23import gleeunit/should
24
25/// A session over `installed`, every one of them enabled.
26fn host(
27  installed: List(extension.Extension),
28) -> #(runtime.Runtime, runtime.Session) {
29  let assert Ok(host) =
30    runtime.start_with_config(
31      ":memory:",
32      extensions.Config(
33        installed,
34        list.map(installed, fn(extension) { extension.name }),
35      ),
36    )
37  let ledger = runtime.ledger(host)
38  let assert Ok(_) = conversation.initialise(ledger)
39  let assert Ok(_) = family.initialise(ledger)
40  let assert Ok(_) =
41    conversation.create(
42      ledger,
43      conversation.Info(
44        "broken-extension-test",
45        "new session",
46        "/tmp",
47        "provider",
48        "model",
49        types.Responses,
50        conversation.Idle,
51        None,
52        None,
53      ),
54    )
55  let assert Ok(session) =
56    runtime.open_session(host, "broken-extension-test", "/tmp")
57  #(host, session)
58}
59
60/// An extension whose only tool answers, named after it.
61fn healthy(name: String) -> extension.Extension {
62  extension.Extension(
63    name,
64    "answers",
65    [],
66    [extension.ToolPlugin("", [answering(name)], [], [])],
67    extension.no_initialise,
68  )
69}
70
71fn answering(name: String) -> extension.Tool {
72  tool.text(
73    name,
74    "answers",
75    False,
76    [],
77    [],
78    decode.success(Nil),
79    "usage",
80    fn(_, _) { Ok("answered") },
81  )
82}
83
84fn tool_only(name: String, value: extension.Tool) -> extension.Extension {
85  extension.Extension(
86    name,
87    "one tool",
88    [],
89    [extension.ToolPlugin("", [value], [], [])],
90    extension.no_initialise,
91  )
92}
93
94fn plugin_only(name: String, plugin: extension.Plugin) -> extension.Extension {
95  extension.Extension(name, "one plugin", [], [plugin], extension.no_initialise)
96}
97
98fn tool_names(session: runtime.Session) -> List(String) {
99  runtime.tools(session) |> list.map(fn(tool) { tool.name })
100}
101
102fn warned_about(session: runtime.Session, name: String) -> Bool {
103  runtime.warnings(session)
104  |> list.any(fn(warning) { string.contains(warning, name) })
105}
106
107fn answer(
108  host: runtime.Runtime,
109  session: runtime.Session,
110  name: String,
111) -> String {
112  let assert Ok(types.ToolOutput(_, body, _)) =
113    runtime.invoke(
114      host,
115      session,
116      types.ToolCall("call_1", name, "{}"),
117      types.any_images,
118    )
119  body
120}
121
122pub fn a_failed_or_crashed_context_load_keeps_the_session_test() -> Nil {
123  let #(host, session) =
124    host([
125      plugin_only("sulky", extension.ContextPlugin(fn(_) { Error("no root") })),
126      plugin_only("exploding", extension.ContextPlugin(fn(_) { panic as "bug" })),
127      healthy("working"),
128    ])
129  tool_names(session) |> should.equal(["working"])
130  warned_about(session, "sulky") |> should.be_true
131  warned_about(session, "exploding") |> should.be_true
132  runtime.stop(host)
133}
134
135pub fn a_failed_or_crashed_prepare_keeps_the_session_test() -> Nil {
136  let #(host, session) =
137    host([
138      plugin_only(
139        "sulky",
140        extension.ManagedPlugin(fn(_, _, _) { Error("no server") }),
141      ),
142      plugin_only(
143        "exploding",
144        extension.ManagedPlugin(fn(_, _, _) { panic as "bug" }),
145      ),
146      healthy("working"),
147    ])
148  tool_names(session) |> should.equal(["working"])
149  warned_about(session, "sulky") |> should.be_true
150  warned_about(session, "exploding") |> should.be_true
151  runtime.stop(host)
152}
153
154/// A static collision is refused when the extension is selected; one a
155/// managed plugin only discovers while preparing, as an MCP server's tool
156/// list does, reaches the session and must lose there.
157pub fn a_colliding_extension_loses_only_itself_test() -> Nil {
158  let #(host, session) =
159    host([
160      healthy("echo"),
161      plugin_only(
162        "squatter",
163        extension.ManagedPlugin(fn(_, _, _) {
164          Ok(extension.Managed(..extension.empty(), tools: [answering("echo")]))
165        }),
166      ),
167    ])
168  tool_names(session) |> should.equal(["echo"])
169  warned_about(session, "squatter") |> should.be_true
170  answer(host, session, "echo") |> should.equal("answered")
171  runtime.stop(host)
172}
173
174pub fn a_crashing_tool_answers_instead_of_ending_the_turn_test() -> Nil {
175  let #(host, session) =
176    host([
177      tool_only(
178        "exploding",
179        extension.Tool(
180          types.Tool("boom", "crashes", tool.schema([], []), False),
181          fn(_, _) { panic as "tool bug" },
182          fn(_) { None },
183        ),
184      ),
185    ])
186  let reason = error_of(answer(host, session, "boom"))
187  string.contains(reason, "boom crashed") |> should.be_true
188  string.contains(reason, "inspect") |> should.be_true
189  runtime.stop(host)
190}
191
192pub fn a_fatal_tool_still_ends_the_turn_test() -> Nil {
193  let #(host, session) =
194    host([
195      tool_only(
196        "strict",
197        extension.Tool(
198          types.Tool("record", "cannot record", tool.schema([], []), False),
199          fn(_, _) { Error(extension.Fatal("transcript storage failed")) },
200          fn(_) { None },
201        ),
202      ),
203    ])
204  runtime.invoke(
205    host,
206    session,
207    types.ToolCall("call_1", "record", "{}"),
208    types.any_images,
209  )
210  |> should.equal(Error("transcript storage failed"))
211  runtime.stop(host)
212}
213
214pub fn a_crashing_observer_leaves_the_session_working_test() -> Nil {
215  let #(host, session) =
216    host([
217      plugin_only(
218        "exploding",
219        extension.ManagedPlugin(fn(_, _, _) {
220          Ok(
221            extension.Managed(..extension.empty(), observe: fn(_, _) {
222              panic as "observer bug"
223            }),
224          )
225        }),
226      ),
227      healthy("working"),
228    ])
229  runtime.observe(
230    session,
231    extension.Session(
232      "broken-extension-test",
233      fn(_, _) { Error("no upstream") },
234      fn(_) { Nil },
235      fn() { False },
236    ),
237    extension.Stirred,
238  )
239  answer(host, session, "working") |> should.equal("answered")
240  runtime.stop(host)
241}
242
243fn error_of(body: String) -> String {
244  let assert Ok(reason) =
245    json.parse(body, decode.field("error", decode.string, decode.success))
246  reason
247}
248
249/// An extension whose initialiser `fails`, for the quarantine cases.
250fn uninstallable(name: String, fails: fn() -> Nil) -> extension.Extension {
251  extension.Extension(
252    name,
253    "will not install",
254    [],
255    [extension.ToolPlugin("", [answering(name)], [], [])],
256    fn(_) {
257      fails()
258      Error("no tables")
259    },
260  )
261}
262
263fn summary(
264  host: runtime.Runtime,
265  name: String,
266) -> Result(extension.Summary, Nil) {
267  let assert Ok(summaries) =
268    runtime.extension_summaries(host, "broken-extension-test")
269  list.find(summaries, fn(summary) { summary.name == name })
270}
271
272pub fn an_extension_that_cannot_install_is_quarantined_test() -> Nil {
273  let #(host, session) =
274    host([
275      uninstallable("sulky", fn() { Nil }),
276      uninstallable("exploding", fn() { panic as "initialiser bug" }),
277      healthy("working"),
278    ])
279  tool_names(session) |> should.equal(["working"])
280  let assert Ok(extension.Summary(quarantined: Some(reason), enabled: False, ..)) =
281    summary(host, "sulky")
282  string.contains(reason, "no tables") |> should.be_true
283  let assert Ok(extension.Summary(quarantined: Some(crash), ..)) =
284    summary(host, "exploding")
285  string.contains(crash, "initialiser bug") |> should.be_true
286  runtime.stop(host)
287}
288
289pub fn a_quarantined_requirement_takes_its_dependents_with_it_test() -> Nil {
290  let #(host, session) =
291    host([
292      uninstallable("engine", fn() { Nil }),
293      extension.Extension(
294        "rider",
295        "needs the engine",
296        ["engine"],
297        [extension.ToolPlugin("", [answering("rider")], [], [])],
298        extension.no_initialise,
299      ),
300      healthy("working"),
301    ])
302  tool_names(session) |> should.equal(["working"])
303  let assert Ok(extension.Summary(quarantined: Some(reason), ..)) =
304    summary(host, "rider")
305  string.contains(reason, "it requires engine") |> should.be_true
306  runtime.stop(host)
307}
308
309pub fn a_quarantined_extension_cannot_be_enabled_test() -> Nil {
310  let #(host, _) =
311    host([uninstallable("sulky", fn() { Nil }), healthy("working")])
312  let assert Error(refusal) =
313    runtime.reload_extension(
314      host,
315      "broken-extension-test",
316      "/tmp",
317      "sulky",
318      True,
319    )
320  string.contains(refusal, "quarantined") |> should.be_true
321  runtime.stop(host)
322}
323
324/// Mixed plugins and transitive failures require synthetic registry entries;
325/// the daemon only installs its compiled registry.
326pub fn a_failed_bundle_removes_all_capabilities_and_dependents_test() -> Nil {
327  let assert Ok(ledger) = store.start(":memory:", "")
328  let closed = process.new_subject()
329  let managed = fn(name) {
330    extension.ManagedPlugin(fn(_, _, _) {
331      Ok(
332        extension.Managed(
333          ..extension.empty(),
334          tools: [answering(name)],
335          close: fn() { process.send(closed, name) },
336        ),
337      )
338    })
339  }
340  let extensions = [
341    extension.Extension(
342      "dependent",
343      "dependent",
344      ["broken"],
345      [managed("dependent")],
346      extension.no_initialise,
347    ),
348    extension.Extension(
349      "transitive",
350      "transitive",
351      ["dependent"],
352      [managed("transitive")],
353      extension.no_initialise,
354    ),
355    extension.Extension(
356      "broken",
357      "mixed",
358      [],
359      [
360        extension.ToolPlugin("must disappear", [answering("static")], [], []),
361        managed("prepared"),
362        extension.ManagedPlugin(fn(_, _, _) { Error("prepare refused") }),
363      ],
364      extension.no_initialise,
365    ),
366    healthy("working"),
367  ]
368  let composed = composition.compose(extensions, ledger, "s", "/tmp")
369  composition.tools(composed)
370  |> list.map(fn(tool) { tool.definition.name })
371  |> should.equal(["working"])
372  composition.extensions(composed)
373  |> list.map(fn(item) { item.name })
374  |> should.equal(["working"])
375  composition.requested(composed) |> list.length |> should.equal(4)
376  composition.inactive(composed) |> list.length |> should.equal(3)
377  composition.instructions(composed) |> should.equal("")
378  process.receive(closed, 100) |> should.equal(Ok("prepared"))
379  process.receive(closed, 100) |> should.equal(Ok("transitive"))
380  process.receive(closed, 100) |> should.equal(Ok("dependent"))
381  composition.close(composed)
382  process.receive(closed, 0) |> should.equal(Error(Nil))
383  store.close(ledger)
384}
385
386pub fn context_failure_never_prepares_the_rest_of_the_bundle_test() -> Nil {
387  let assert Ok(ledger) = store.start(":memory:", "")
388  let prepared = process.new_subject()
389  let composed =
390    composition.compose(
391      [
392        extension.Extension(
393          "broken",
394          "context",
395          [],
396          [
397            extension.ContextPlugin(fn(_) { Error("context refused") }),
398            extension.ManagedPlugin(fn(_, _, _) {
399              process.send(prepared, Nil)
400              Ok(extension.empty())
401            }),
402          ],
403          extension.no_initialise,
404        ),
405      ],
406      ledger,
407      "s",
408      "/tmp",
409    )
410  composition.extensions(composed) |> should.equal([])
411  process.receive(prepared, 0) |> should.equal(Error(Nil))
412  composition.close(composed)
413  store.close(ledger)
414}
415
416pub fn registry_order_resolves_whole_bundle_collisions_test() -> Nil {
417  let assert Ok(ledger) = store.start(":memory:", "")
418  let closed = process.new_subject()
419  let composed =
420    composition.compose(
421      [
422        plugin_only(
423          "first",
424          extension.ManagedPlugin(fn(_, _, _) {
425            Ok(
426              extension.Managed(..extension.empty(), tools: [
427                answering("claimed"),
428              ]),
429            )
430          }),
431        ),
432        extension.Extension(
433          "loser",
434          "mixed",
435          [],
436          [
437            extension.ToolPlugin(
438              "",
439              [answering("claimed"), answering("extra")],
440              [],
441              [],
442            ),
443            extension.ManagedPlugin(fn(_, _, _) {
444              Ok(
445                extension.Managed(..extension.empty(), close: fn() {
446                  process.send(closed, Nil)
447                }),
448              )
449            }),
450          ],
451          extension.no_initialise,
452        ),
453      ],
454      ledger,
455      "s",
456      "/tmp",
457    )
458  composition.tools(composed)
459  |> list.map(fn(tool) { tool.definition.name })
460  |> should.equal(["claimed"])
461  composition.extensions(composed)
462  |> list.map(fn(item) { item.name })
463  |> should.equal(["first"])
464  process.receive(closed, 100) |> should.equal(Ok(Nil))
465  composition.close(composed)
466  process.receive(closed, 0) |> should.equal(Error(Nil))
467  store.close(ledger)
468}