| 332 |
332
|
|
end
|
| 333 |
333
|
|
end
|
| 334 |
334
|
|
|
|
335
|
+ |
defp set_config(key, value) do
|
|
336
|
+ |
previous = Application.get_env(:openagents, key)
|
|
337
|
+ |
Application.put_env(:openagents, key, value)
|
|
338
|
+ |
|
|
339
|
+ |
on_exit(fn ->
|
|
340
|
+ |
Application.put_env(:openagents, key, previous)
|
|
341
|
+ |
end)
|
|
342
|
+ |
end
|
|
343
|
+ |
|
|
344
|
+ |
describe "delegated child threads" do
|
|
345
|
+ |
test "a child thread names its parent and stays on the same account" do
|
|
346
|
+ |
user = owner("child-parent")
|
|
347
|
+ |
{:ok, parent, _grant, _token} = Threads.open_and_mint(user, "Parent")
|
|
348
|
+ |
{:ok, child} = Threads.open(user, "Child", parent_thread_id: parent.id)
|
|
349
|
+ |
|
|
350
|
+ |
assert child.parent_thread_id == parent.id
|
|
351
|
+ |
assert child.owner_visitor_id == parent.owner_visitor_id
|
|
352
|
+ |
end
|
|
353
|
+ |
|
|
354
|
+ |
test "a child cannot name another account's parent" do
|
|
355
|
+ |
mine = owner("child-owner-mine")
|
|
356
|
+ |
theirs = owner("child-owner-theirs")
|
|
357
|
+ |
{:ok, parent, _grant, _token} = Threads.open_and_mint(mine, "Parent")
|
|
358
|
+ |
|
|
359
|
+ |
assert {:error, changeset} =
|
|
360
|
+ |
Threads.open(theirs, "Child", parent_thread_id: parent.id)
|
|
361
|
+ |
|
|
362
|
+ |
assert %{parent_thread_id: _} = errors_on(changeset)
|
|
363
|
+ |
end
|
|
364
|
+ |
|
|
365
|
+ |
test "a child cannot name a terminal parent" do
|
|
366
|
+ |
user = owner("child-terminal-parent")
|
|
367
|
+ |
{:ok, parent, _grant, _token} = Threads.open_and_mint(user, "Parent")
|
|
368
|
+ |
{:ok, parent} = Threads.finish(parent, %{report: "Done."})
|
|
369
|
+ |
|
|
370
|
+ |
assert {:error, changeset} =
|
|
371
|
+ |
Threads.open(user, "Child", parent_thread_id: parent.id)
|
|
372
|
+ |
|
|
373
|
+ |
assert %{parent_thread_id: _} = errors_on(changeset)
|
|
374
|
+ |
end
|
|
375
|
+ |
|
|
376
|
+ |
test "a child is refused when the parent holds no active grant" do
|
|
377
|
+ |
user = owner("child-no-grant")
|
|
378
|
+ |
{:ok, parent} = Threads.open(user, "Parent")
|
|
379
|
+ |
|
|
380
|
+ |
assert {:error, :parent_authority_exhausted} =
|
|
381
|
+ |
Threads.open_and_mint(user, "Child", parent_thread_id: parent.id)
|
|
382
|
+ |
end
|
|
383
|
+ |
|
|
384
|
+ |
test "a child inherits its parent's visibility" do
|
|
385
|
+ |
user = owner("child-visibility-inherit")
|
|
386
|
+ |
{:ok, parent} = Threads.open(user, "Parent", visibility: "ledger")
|
|
387
|
+ |
{:ok, child} = Threads.open(user, "Child", parent_thread_id: parent.id)
|
|
388
|
+ |
|
|
389
|
+ |
assert child.visibility == "ledger"
|
|
390
|
+ |
end
|
|
391
|
+ |
|
|
392
|
+ |
test "a child cannot be opened wider than its parent" do
|
|
393
|
+ |
user = owner("child-visibility-wide")
|
|
394
|
+ |
{:ok, parent} = Threads.open(user, "Parent", visibility: "dark")
|
|
395
|
+ |
|
|
396
|
+ |
assert {:error, changeset} =
|
|
397
|
+ |
Threads.open(user, "Child",
|
|
398
|
+ |
parent_thread_id: parent.id,
|
|
399
|
+ |
visibility: "ledger"
|
|
400
|
+ |
)
|
|
401
|
+ |
|
|
402
|
+ |
assert %{visibility: _} = errors_on(changeset)
|
|
403
|
+ |
end
|
|
404
|
+ |
|
|
405
|
+ |
test "a child counts toward the admission cap" do
|
|
406
|
+ |
cap(2)
|
|
407
|
+ |
user = owner("child-cap")
|
|
408
|
+ |
{:ok, parent} = Threads.open(user, "Parent")
|
|
409
|
+ |
assert {:ok, _child} = Threads.open(user, "Child", parent_thread_id: parent.id)
|
|
410
|
+ |
assert {:error, :thread_quota_reached} = Threads.open(user, "Third")
|
|
411
|
+ |
end
|
|
412
|
+ |
|
|
413
|
+ |
test "spawning a child appends a thread.spawn event to the parent transcript" do
|
|
414
|
+ |
user = owner("child-spawn")
|
|
415
|
+ |
{:ok, parent, _grant, _token} = Threads.open_and_mint(user, "Parent")
|
|
416
|
+ |
{:ok, child} = Threads.open(user, "Child", parent_thread_id: parent.id)
|
|
417
|
+ |
|
|
418
|
+ |
parent = Threads.get_for_user(user, parent.id)
|
|
419
|
+ |
events = Threads.list_events(parent)
|
|
420
|
+ |
|
|
421
|
+ |
assert [%Event{event_type: "thread.opened"}, %Event{event_type: "thread.spawn"}] = events
|
|
422
|
+ |
spawn = List.last(events)
|
|
423
|
+ |
assert spawn.payload["child_thread_id"] == child.id
|
|
424
|
+ |
assert parent.event_count == 2
|
|
425
|
+ |
end
|
|
426
|
+ |
end
|
|
427
|
+ |
|
|
428
|
+ |
describe "child thread ceilings" do
|
|
429
|
+ |
test "a child grant is ceiled at the parent's remaining calls" do
|
|
430
|
+ |
set_config(:thread_grant_max_calls, 5)
|
|
431
|
+ |
set_config(:thread_grant_max_total_tokens, nil)
|
|
432
|
+ |
user = owner("child-calls")
|
|
433
|
+ |
{:ok, parent, grant, _token} = Threads.open_and_mint(user, "Parent")
|
|
434
|
+ |
{:ok, spent} = Inference.record_usage(grant, %{"output_tokens" => 1})
|
|
435
|
+ |
{:ok, spent} = Inference.record_usage(spent, %{"output_tokens" => 1})
|
|
436
|
+ |
assert spent.call_count == 2
|
|
437
|
+ |
|
|
438
|
+ |
{:ok, _child, child_grant, _token} =
|
|
439
|
+ |
Threads.open_and_mint(user, "Child", parent_thread_id: parent.id)
|
|
440
|
+ |
|
|
441
|
+ |
assert child_grant.max_calls == 3
|
|
442
|
+ |
end
|
|
443
|
+ |
|
|
444
|
+ |
test "a child grant is ceiled at the parent's remaining tokens" do
|
|
445
|
+ |
set_config(:thread_grant_max_total_tokens, 100)
|
|
446
|
+ |
set_config(:thread_grant_max_calls, nil)
|
|
447
|
+ |
user = owner("child-tokens")
|
|
448
|
+ |
{:ok, parent, grant, _token} = Threads.open_and_mint(user, "Parent")
|
|
449
|
+ |
{:ok, spent} = Inference.record_usage(grant, %{"output_tokens" => 30})
|
|
450
|
+ |
assert spent.usage["total_tokens"] == 30
|
|
451
|
+ |
|
|
452
|
+ |
{:ok, _child, child_grant, _token} =
|
|
453
|
+ |
Threads.open_and_mint(user, "Child", parent_thread_id: parent.id)
|
|
454
|
+ |
|
|
455
|
+ |
assert child_grant.max_total_tokens == 70
|
|
456
|
+ |
end
|
|
457
|
+ |
|
|
458
|
+ |
test "a child grant is ceiled at the parent's remaining cost" do
|
|
459
|
+ |
set_config(:account_credit_microusd, 100_000)
|
|
460
|
+ |
set_config(:visitor_credit_microusd, 100_000)
|
|
461
|
+ |
set_config(:thread_grant_max_calls, nil)
|
|
462
|
+ |
set_config(:thread_grant_max_total_tokens, nil)
|
|
463
|
+ |
user = owner("child-cost")
|
|
464
|
+ |
{:ok, parent, grant, _token} = Threads.open_and_mint(user, "Parent")
|
|
465
|
+ |
{:ok, spent} = Inference.record_usage(grant, %{"output_tokens" => 1000})
|
|
466
|
+ |
assert spent.usage["estimated_cost_microusd"] == 10_000
|
|
467
|
+ |
|
|
468
|
+ |
{:ok, _child, child_grant, _token} =
|
|
469
|
+ |
Threads.open_and_mint(user, "Child", parent_thread_id: parent.id)
|
|
470
|
+ |
|
|
471
|
+ |
assert child_grant.max_cost_microusd == 90_000
|
|
472
|
+ |
end
|
|
473
|
+ |
end
|
|
474
|
+ |
|
|
475
|
+ |
describe "typed thread reports" do
|
|
476
|
+ |
test "completing a thread records the requested report type" do
|
|
477
|
+ |
user = owner("typed-report")
|
|
478
|
+ |
{:ok, thread} = Threads.open(user, "Objective")
|
|
479
|
+ |
{:ok, finished} = Threads.finish(thread, %{report: "Done.", report_type: "result"})
|
|
480
|
+ |
|
|
481
|
+ |
assert finished.report_type == "result"
|
|
482
|
+ |
end
|
|
483
|
+ |
|
|
484
|
+ |
test "finish defaults the report type when the caller names none" do
|
|
485
|
+ |
user = owner("typed-report-default")
|
|
486
|
+ |
{:ok, thread} = Threads.open(user, "Objective")
|
|
487
|
+ |
{:ok, finished} = Threads.finish(thread, %{report: "Done."})
|
|
488
|
+ |
|
|
489
|
+ |
assert finished.report_type == "outcome"
|
|
490
|
+ |
end
|
|
491
|
+ |
end
|
|
492
|
+ |
|
| 335 |
493
|
|
defp cap(limit) do
|
| 336 |
494
|
|
previous = Application.get_env(:openagents, :maximum_open_threads_per_account)
|
| 337 |
495
|
|
Application.put_env(:openagents, :maximum_open_threads_per_account, limit)
|