diff --git a/TERMINOLOGY.md b/TERMINOLOGY.md index 2fbec4372..70b508db6 100644 --- a/TERMINOLOGY.md +++ b/TERMINOLOGY.md @@ -46,6 +46,10 @@ Canonical words used across Junior's code and documentation. - **Session record**: the persisted read model for one resumable turn. - **Conversation execution**: mutable operational state for a conversation, such as mailbox state, worker lease, checkpoints, and activity status. +- **Agent binding**: a named reference, scoped to one parent agent + conversation, that reuses one child conversation and its history. +- **Agent invocation**: one retry-safe delegated task sent from a parent agent + conversation to a child conversation, including its durable terminal result. - **Reasoning level**: the configured or selected amount of model reasoning for a turn: `none`, `low`, `medium`, `high`, or `xhigh`. - **Reply**: a destination-visible message owned by delivery or reply-policy @@ -54,6 +58,9 @@ Canonical words used across Junior's code and documentation. ## Naming Guidance - Use `turn`, `run`, and `slice` only with the meanings above. +- Use `agent invocation` for delegated child work; do not shorten it to + `invocation` where it could be confused with a model or serverless + invocation. - Use `message` for platform chat content. Use `user_message`, `assistant_message`, and `tool_result` for replayable agent history. - Use `agent history item` when referring to those three native event types as diff --git a/packages/junior/migrations/0012_sour_vargas.sql b/packages/junior/migrations/0012_sour_vargas.sql new file mode 100644 index 000000000..f5e4a021e --- /dev/null +++ b/packages/junior/migrations/0012_sour_vargas.sql @@ -0,0 +1,36 @@ +CREATE TABLE "junior_agent_bindings" ( + "parent_conversation_id" text NOT NULL, + "name" text NOT NULL, + "child_conversation_id" text NOT NULL, + "reasoning_level" text, + CONSTRAINT "junior_agent_bindings_parent_conversation_id_name_pk" PRIMARY KEY("parent_conversation_id","name") +); +--> statement-breakpoint +CREATE TABLE "junior_agent_invocations" ( + "invocation_id" text PRIMARY KEY NOT NULL, + "parent_conversation_id" text NOT NULL, + "child_conversation_id" text NOT NULL, + "agent_name" text, + "input" text NOT NULL, + "actor_json" jsonb NOT NULL, + "credential_context_json" jsonb, + "source_json" jsonb NOT NULL, + "destination_json" jsonb NOT NULL, + "destination_visibility" text, + "reasoning_level" text, + "status" text NOT NULL, + "mailbox_status" text NOT NULL, + "result" text, + "error_message" text, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + "terminal_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "junior_agent_bindings" ADD CONSTRAINT "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk" FOREIGN KEY ("parent_conversation_id") REFERENCES "public"."junior_conversations"("conversation_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "junior_agent_bindings" ADD CONSTRAINT "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk" FOREIGN KEY ("child_conversation_id") REFERENCES "public"."junior_conversations"("conversation_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "junior_agent_invocations" ADD CONSTRAINT "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk" FOREIGN KEY ("parent_conversation_id") REFERENCES "public"."junior_conversations"("conversation_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "junior_agent_invocations" ADD CONSTRAINT "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk" FOREIGN KEY ("child_conversation_id") REFERENCES "public"."junior_conversations"("conversation_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "junior_agent_bindings_child_idx" ON "junior_agent_bindings" USING btree ("child_conversation_id");--> statement-breakpoint +CREATE INDEX "junior_agent_invocations_child_idx" ON "junior_agent_invocations" USING btree ("child_conversation_id");--> statement-breakpoint +CREATE INDEX "junior_agent_invocations_mailbox_idx" ON "junior_agent_invocations" USING btree ("mailbox_status","created_at"); \ No newline at end of file diff --git a/packages/junior/migrations/meta/0012_snapshot.json b/packages/junior/migrations/meta/0012_snapshot.json new file mode 100644 index 000000000..e5b34f51f --- /dev/null +++ b/packages/junior/migrations/meta/0012_snapshot.json @@ -0,0 +1,1449 @@ +{ + "id": "dd2cb406-092d-4827-9a80-953bf6d43d5b", + "prevId": "d68ca73e-2d45-4adf-a955-7226d68dddcf", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": ["parent_conversation_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": ["conversation_id", "plugin", "kind", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_origin_idx": { + "name": "junior_conversations_origin_idx", + "columns": [ + { + "expression": "origin_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": ["destination_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["credential_subject_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["root_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": ["date", "namespace", "metric", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index ace62000a..ffd59023e 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1785271740428, "tag": "0011_sour_golden_guardian", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1785433307522, + "tag": "0012_sour_vargas", + "breakpoints": true } ] } diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index e1ca482f3..3c6c4ac55 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -63,6 +63,7 @@ import { type VercelConversationWorkCallbackOptions, } from "@/chat/task-execution/vercel-callback"; import { getVercelConversationWorkQueue } from "@/chat/task-execution/vercel-queue"; +import { bindSpawnAgent } from "@/chat/agent-invocations/spawn"; import { createVercelPluginTaskCallback, registerVercelPluginTaskDevConsumer, @@ -632,7 +633,10 @@ export async function createApp(options?: JuniorAppOptions): Promise { const waitUntil = options?.waitUntil ?? (await defaultWaitUntil()); const tracePropagation = { domains: sandboxEgressTracePropagationDomains }; + const conversationWorkQueue = getVercelConversationWorkQueue(); const agentRunner = createAgentRunner(executeAgentRun, { + bindSpawnAgent: (request) => + bindSpawnAgent(request, { queue: conversationWorkQueue }), tracePropagation, }); const runtimeServiceOverrides = { diff --git a/packages/junior/src/chat/README.md b/packages/junior/src/chat/README.md index 4f708df9d..ef79442a5 100644 --- a/packages/junior/src/chat/README.md +++ b/packages/junior/src/chat/README.md @@ -9,7 +9,8 @@ file. 1. `ingress/` parses, classifies, and normalizes source events. 2. Mailbox-backed sources append work and send a queue nudge through - `task-execution/`. + `task-execution/`. `agent-invocations/` uses the same mailbox for + destinationless child work. 3. A worker acquires the conversation lease, drains pending input, and restores persisted conversation state. 4. `runtime/` prepares and orchestrates the run; `agent/` owns Pi execution. @@ -34,6 +35,8 @@ mailbox-backed provider. - `runtime/`: turn orchestration and provider-neutral delivery callbacks. - `agent-dispatch/`: plugin dispatch authority, mailbox adaptation, and plugin-facing outcome projection. +- `agent-invocations/`: durable parent/child bindings, delegated work, and + internal terminal results. - `agent/` and `pi/`: model execution and Pi state conversion. - `services/`: consumer-owned domain decisions. - `state/` and `conversations/`: persistence by concern. @@ -90,8 +93,10 @@ delegation without becoming the execution actor or a general task owner. back past delivered assistant output. - Unexpected failures propagate to the boundary that owns capture and fallback delivery. -- Actor, destination, conversation, and credential context remain explicit - across asynchronous boundaries. +- Actor, execution destination, conversation, and credential context remain + explicit across asynchronous boundaries. A destinationless child + conversation receives its bounded execution destination from its durable + agent invocation. Follow `../../../../policies/context-bound-systems.md`, `../../../../policies/provider-boundaries.md`, and the feature READMEs in diff --git a/packages/junior/src/chat/agent-dispatch/heartbeat.ts b/packages/junior/src/chat/agent-dispatch/heartbeat.ts index 5093f75dd..75e42549f 100644 --- a/packages/junior/src/chat/agent-dispatch/heartbeat.ts +++ b/packages/junior/src/chat/agent-dispatch/heartbeat.ts @@ -4,6 +4,8 @@ import { recoverConversationWork } from "@/chat/task-execution/heartbeat"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { getVercelConversationWorkQueue } from "@/chat/task-execution/vercel-queue"; import { createHeartbeatContext } from "./context"; +import { listPendingAgentInvocationMailboxAppends } from "@/chat/agent-invocations/store"; +import { enqueueAgentInvocation } from "@/chat/agent-invocations/work"; import { confirmDispatchMailboxAppend, getDispatchRecord, @@ -16,6 +18,7 @@ import { AGENT_DISPATCH_MAX_AGE_MS, enqueueAgentDispatch } from "./work"; const DEFAULT_PLUGIN_LIMIT = 25; const PLUGIN_HEARTBEAT_TIMEOUT_MS = 25_000; const DISPATCH_MAILBOX_APPEND_LIMIT = 100; +const AGENT_INVOCATION_MAILBOX_APPEND_LIMIT = 100; async function runWithTimeout( promise: Promise, timeoutMs: number, @@ -125,6 +128,28 @@ export async function recoverPendingDispatchMailboxAppends(args: { } } +/** Repair invocation creation that stopped before its idempotent mailbox append. */ +export async function recoverPendingAgentInvocationMailboxAppends(args: { + conversationWorkQueue: ConversationWorkQueue; + nowMs: number; +}): Promise { + const invocations = await listPendingAgentInvocationMailboxAppends( + AGENT_INVOCATION_MAILBOX_APPEND_LIMIT, + ); + for (const invocation of invocations) { + try { + await enqueueAgentInvocation(invocation, { + nowMs: args.nowMs, + queue: args.conversationWorkQueue, + }); + } catch (error) { + logException(error, "agent.invocation.mailbox.append_recovery.failed", { + "app.agent.invocation_id": invocation.invocationId, + }); + } + } +} + /** Run the core heartbeat phases. */ export async function runHeartbeat(args: { conversationWorkQueue?: ConversationWorkQueue; @@ -139,6 +164,10 @@ export async function runHeartbeat(args: { conversationWorkQueue: queue, nowMs: args.nowMs, }); + await recoverPendingAgentInvocationMailboxAppends({ + conversationWorkQueue: queue, + nowMs: args.nowMs, + }); await runPluginHeartbeats({ conversationWorkQueue: queue, nowMs: args.nowMs, diff --git a/packages/junior/src/chat/agent-dispatch/work.ts b/packages/junior/src/chat/agent-dispatch/work.ts index 0d18a5518..fb162aabb 100644 --- a/packages/junior/src/chat/agent-dispatch/work.ts +++ b/packages/junior/src/chat/agent-dispatch/work.ts @@ -426,6 +426,7 @@ export function createAgentDispatchConversationWorker( ); } if ( + !context.destination || context.destination.platform !== dispatch.destination.platform || context.destination.teamId !== dispatch.destination.teamId || context.destination.channelId !== dispatch.destination.channelId diff --git a/packages/junior/src/chat/agent-invocations/README.md b/packages/junior/src/chat/agent-invocations/README.md new file mode 100644 index 000000000..6f8409365 --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/README.md @@ -0,0 +1,55 @@ +# Agent Invocations + +This module owns durable parent-to-child agent work. It gives delegated work a +stable identity, schedules it through the shared conversation mailbox, and +stores the terminal result for its parent to read later. + +## Records + +- An **agent binding** maps one name within a parent agent conversation to one + destinationless child conversation and its reasoning policy. Reusing the + name reuses that child's history; an omitted reasoning level inherits the + binding, while an explicit mismatch is rejected. +- An **agent invocation** is one retry-safe task sent to a child. Its + `invocationId` is derived from the parent conversation and caller-supplied + idempotency key. +- An invocation without a name gets an invocation-scoped child conversation. +- Child conversation lineage is immutable. Bindings and invocation content are + purged with their root conversation tree. Recursive delegation is disabled + until depth, cancellation, and authority rules are defined. + +SQL owns bindings, invocation status, bounded execution authority, and terminal +results. The conversation event log and session record continue to own agent +history and resumable execution. Mailbox entries contain only the invocation +reference needed to join those records. Invocation content inherits the root +conversation's visibility and retention window and is deleted with that +conversation tree. + +## Execution + +1. Creation writes the invocation before attempting the mailbox append. +2. The idempotent mailbox append sends a normal conversation queue wake. +3. The invocation router recognizes destinationless child work and advances it + through the shared `AgentRunner`. +4. A cooperative yield keeps the same turn and invocation active for another + execution slice. +5. Completion writes the session record first, then projects the immutable + result or error onto the invocation. +6. The heartbeat repairs invocations left in `mailboxStatus: "pending"`. + +The child conversation has no provider destination. Each invocation carries +the actor, credential context, source, and destination that bound its tool +execution. Child output is an internal result; provider delivery remains owned +by the parent-facing runtime. + +## Current Boundary + +`spawnAgent` exposes durable creation to a parent agent. The tool receives only +the delegated task, optional child name, and optional reasoning level. The +runtime derives actor, credentials, destination, visibility, source, parent +conversation, and idempotency from the active tool call. + +This slice does not yet expose result recovery, inject child results into a +parent turn, support recursive children, or implement cancellation. Those +behaviors should build on the invocation record rather than introducing another +scheduler or execution loop. diff --git a/packages/junior/src/chat/agent-invocations/errors.ts b/packages/junior/src/chat/agent-invocations/errors.ts new file mode 100644 index 000000000..ffb451595 --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/errors.ts @@ -0,0 +1,7 @@ +/** Named child cannot accept another invocation until its active work ends. */ +export class AgentInvocationBusyError extends Error { + constructor(name: string) { + super(`Named agent "${name}" already has active work`); + this.name = "AgentInvocationBusyError"; + } +} diff --git a/packages/junior/src/chat/agent-invocations/spawn.ts b/packages/junior/src/chat/agent-invocations/spawn.ts new file mode 100644 index 000000000..22fc9780c --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/spawn.ts @@ -0,0 +1,62 @@ +import type { StateAdapter } from "chat"; +import type { ConversationStore } from "@/chat/conversations/store"; +import type { + AgentRunRequest, + SpawnAgent, + SpawnAgentInput, +} from "@/chat/agent/request"; +import { + actorFromRouting, + toolInvocationDestination, +} from "@/chat/agent/request"; +import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; +import { createAndEnqueueAgentInvocation } from "./work"; + +type SpawnOptions = { + conversationStore?: ConversationStore; + queue: ConversationWorkQueue | (() => ConversationWorkQueue); + state?: StateAdapter; +}; + +/** Bind one run's runtime-owned authority to the model-safe spawn capability. */ +export function bindSpawnAgent( + request: AgentRunRequest, + options: SpawnOptions, +): SpawnAgent | undefined { + const actor = actorFromRouting(request.routing); + if (!actor) { + return undefined; + } + return async ( + input: SpawnAgentInput, + call: { signal?: AbortSignal; toolCallId: string }, + ) => { + call.signal?.throwIfAborted(); + const queue = + typeof options.queue === "function" ? options.queue() : options.queue; + const invocation = await createAndEnqueueAgentInvocation( + { + actor, + ...(input.name ? { agentName: input.name } : {}), + ...(request.routing.credentialContext + ? { credentialContext: request.routing.credentialContext } + : {}), + destination: toolInvocationDestination(request.routing), + ...(request.routing.destinationVisibility + ? { + destinationVisibility: request.routing.destinationVisibility, + } + : {}), + idempotencyKey: `${request.turnId}:${call.toolCallId}`, + input: input.task, + parentConversationId: request.conversationId, + ...(input.reasoningLevel + ? { reasoningLevel: input.reasoningLevel } + : {}), + source: request.routing.source, + }, + { ...options, queue }, + ); + return { invocationId: invocation.invocationId }; + }; +} diff --git a/packages/junior/src/chat/agent-invocations/store.ts b/packages/junior/src/chat/agent-invocations/store.ts new file mode 100644 index 000000000..3bb12bf83 --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/store.ts @@ -0,0 +1,432 @@ +import { createHash } from "node:crypto"; +import { and, asc, eq, inArray } from "drizzle-orm"; +import { getConversationStore, getSqlExecutor } from "@/chat/db"; +import { juniorAgentBindings, juniorAgentInvocations } from "@/db/schema"; +import { + agentBindingSchema, + agentInvocationSchema, + createAgentInvocationSchema, + type AgentBinding, + type AgentInvocation, + type AgentInvocationStatus, + type CreateAgentInvocationInput, +} from "./types"; +import { AgentInvocationBusyError } from "./errors"; + +const CREATE_LOCK_PREFIX = "junior:agent_invocation:create"; +const TERMINAL_AGENT_INVOCATION_STATUSES = [ + "blocked", + "completed", + "failed", +] as const satisfies readonly AgentInvocationStatus[]; +const NON_TERMINAL_AGENT_INVOCATION_STATUSES = [ + "pending", + "running", + "awaiting_resume", +] as const satisfies readonly AgentInvocationStatus[]; + +function stableId(prefix: string, ...parts: string[]): string { + const digest = createHash("sha256") + .update(parts.join("\0")) + .digest("hex") + .slice(0, 32); + return `${prefix}:${digest}`; +} + +/** Return the stable identity for one retry-safe delegated task. */ +export function getAgentInvocationId( + parentConversationId: string, + idempotencyKey: string, +): string { + return stableId("agent-invocation", parentConversationId, idempotencyKey); +} + +/** Return the stable child identity for one invocation without a named binding. */ +export function getUnnamedAgentConversationId(invocationId: string): string { + return stableId("agent", invocationId); +} + +/** Return the stable child identity for one named binding. */ +export function getNamedAgentConversationId( + parentConversationId: string, + name: string, +): string { + return stableId("agent", parentConversationId, name); +} + +/** Return the stable turn identity advanced by one agent invocation. */ +export function getAgentInvocationTurnId(invocationId: string): string { + return `agent-invocation:${invocationId}`; +} + +/** Return the stable mailbox identity for one agent invocation. */ +export function getAgentInvocationMessageId(invocationId: string): string { + return `agent-invocation:${invocationId}:input`; +} + +function bindingFromRow( + row: typeof juniorAgentBindings.$inferSelect, +): AgentBinding { + return agentBindingSchema.parse({ + childConversationId: row.childConversationId, + name: row.name, + parentConversationId: row.parentConversationId, + ...(row.reasoningLevel ? { reasoningLevel: row.reasoningLevel } : {}), + }); +} + +function invocationFromRow( + row: typeof juniorAgentInvocations.$inferSelect, +): AgentInvocation { + return agentInvocationSchema.parse({ + actor: row.actor, + ...(row.agentName ? { agentName: row.agentName } : {}), + childConversationId: row.childConversationId, + createdAtMs: row.createdAt.getTime(), + ...(row.credentialContext + ? { credentialContext: row.credentialContext } + : {}), + destination: row.destination, + ...(row.destinationVisibility + ? { destinationVisibility: row.destinationVisibility } + : {}), + ...(row.errorMessage !== null ? { errorMessage: row.errorMessage } : {}), + input: row.input, + invocationId: row.invocationId, + mailboxStatus: row.mailboxStatus, + parentConversationId: row.parentConversationId, + ...(row.reasoningLevel ? { reasoningLevel: row.reasoningLevel } : {}), + ...(row.result !== null ? { result: row.result } : {}), + source: row.source, + status: row.status, + ...(row.terminalAt ? { terminalAtMs: row.terminalAt.getTime() } : {}), + updatedAtMs: row.updatedAt.getTime(), + }); +} + +/** Require every durable creation input to match before replaying one key. */ +function sameCreateInput( + invocation: AgentInvocation, + input: ReturnType, +): boolean { + return ( + invocation.parentConversationId === input.parentConversationId && + invocation.agentName === input.agentName && + invocation.input === input.input && + invocation.reasoningLevel === input.reasoningLevel && + JSON.stringify(invocation.actor) === JSON.stringify(input.actor) && + JSON.stringify(invocation.credentialContext) === + JSON.stringify(input.credentialContext) && + JSON.stringify(invocation.destination) === + JSON.stringify(input.destination) && + invocation.destinationVisibility === input.destinationVisibility && + JSON.stringify(invocation.source) === JSON.stringify(input.source) + ); +} + +/** Read one named child binding in its parent-agent scope. */ +async function getAgentBinding(args: { + name: string; + parentConversationId: string; +}): Promise { + const rows = await getSqlExecutor() + .db() + .select() + .from(juniorAgentBindings) + .where( + and( + eq(juniorAgentBindings.parentConversationId, args.parentConversationId), + eq(juniorAgentBindings.name, args.name), + ), + ); + return rows[0] ? bindingFromRow(rows[0]) : undefined; +} + +/** Read one durable agent invocation. */ +export async function getAgentInvocation( + invocationId: string, +): Promise { + const rows = await getSqlExecutor() + .db() + .select() + .from(juniorAgentInvocations) + .where(eq(juniorAgentInvocations.invocationId, invocationId)); + return rows[0] ? invocationFromRow(rows[0]) : undefined; +} + +/** Resolve the single invocation that may resume for one child conversation. */ +export async function getActiveAgentInvocationForConversation( + childConversationId: string, +): Promise { + const rows = await getSqlExecutor() + .db() + .select() + .from(juniorAgentInvocations) + .where( + and( + eq(juniorAgentInvocations.childConversationId, childConversationId), + inArray(juniorAgentInvocations.status, ["running", "awaiting_resume"]), + ), + ) + .orderBy(asc(juniorAgentInvocations.createdAt)) + .limit(2); + if (rows.length > 1) { + throw new Error( + `Child conversation ${childConversationId} has multiple active agent invocations`, + ); + } + return rows[0] ? invocationFromRow(rows[0]) : undefined; +} + +async function getNonTerminalAgentInvocationForConversation( + childConversationId: string, +): Promise { + const rows = await getSqlExecutor() + .db() + .select() + .from(juniorAgentInvocations) + .where( + and( + eq(juniorAgentInvocations.childConversationId, childConversationId), + inArray( + juniorAgentInvocations.status, + NON_TERMINAL_AGENT_INVOCATION_STATUSES, + ), + ), + ) + .orderBy(asc(juniorAgentInvocations.createdAt)) + .limit(1); + return rows[0] ? invocationFromRow(rows[0]) : undefined; +} + +/** + * Create or replay one invocation, reusing named child conversations and + * keeping unnamed child identities scoped to the invocation. + */ +export async function createAgentInvocation( + rawInput: CreateAgentInvocationInput, + nowMs = Date.now(), +): Promise { + const input = createAgentInvocationSchema.parse(rawInput); + const invocationId = getAgentInvocationId( + input.parentConversationId, + input.idempotencyKey, + ); + const childConversationId = input.agentName + ? getNamedAgentConversationId(input.parentConversationId, input.agentName) + : getUnnamedAgentConversationId(invocationId); + const lockName = `${CREATE_LOCK_PREFIX}:${ + input.agentName ? childConversationId : invocationId + }`; + return await getSqlExecutor().withLock(lockName, async () => { + const existingBinding = input.agentName + ? await getAgentBinding({ + name: input.agentName, + parentConversationId: input.parentConversationId, + }) + : undefined; + if ( + existingBinding && + input.reasoningLevel !== undefined && + input.reasoningLevel !== existingBinding.reasoningLevel + ) { + throw new Error( + `Named agent binding policy changed for ${input.agentName}`, + ); + } + const effectiveInput = existingBinding?.reasoningLevel + ? { ...input, reasoningLevel: existingBinding.reasoningLevel } + : input; + const existing = await getAgentInvocation(invocationId); + if (existing) { + if (!sameCreateInput(existing, effectiveInput)) { + throw new Error( + `Agent invocation idempotency key was reused with different input for ${invocationId}`, + ); + } + return existing; + } + + await getConversationStore().createChild({ + childConversationId, + parentConversationId: input.parentConversationId, + nowMs, + source: "internal", + }); + + if (input.agentName) { + await getSqlExecutor() + .db() + .insert(juniorAgentBindings) + .values({ + childConversationId, + name: input.agentName, + parentConversationId: input.parentConversationId, + reasoningLevel: effectiveInput.reasoningLevel ?? null, + }) + .onConflictDoNothing(); + const binding = await getAgentBinding({ + name: input.agentName, + parentConversationId: input.parentConversationId, + }); + if (!binding || binding.childConversationId !== childConversationId) { + throw new Error( + `Named agent binding did not resolve to ${childConversationId}`, + ); + } + if (binding.reasoningLevel !== effectiveInput.reasoningLevel) { + throw new Error( + `Named agent binding policy changed for ${input.agentName}`, + ); + } + if ( + await getNonTerminalAgentInvocationForConversation(childConversationId) + ) { + throw new AgentInvocationBusyError(input.agentName); + } + } + + await getSqlExecutor() + .db() + .insert(juniorAgentInvocations) + .values({ + invocationId, + parentConversationId: input.parentConversationId, + childConversationId, + agentName: input.agentName ?? null, + input: effectiveInput.input, + actor: effectiveInput.actor, + credentialContext: effectiveInput.credentialContext ?? null, + source: effectiveInput.source, + destination: effectiveInput.destination, + destinationVisibility: effectiveInput.destinationVisibility ?? null, + reasoningLevel: effectiveInput.reasoningLevel ?? null, + status: "pending", + mailboxStatus: "pending", + createdAt: new Date(nowMs), + updatedAt: new Date(nowMs), + terminalAt: null, + }) + .onConflictDoNothing(); + const invocation = await getAgentInvocation(invocationId); + if (!invocation || !sameCreateInput(invocation, effectiveInput)) { + throw new Error(`Agent invocation creation raced for ${invocationId}`); + } + return invocation; + }); +} + +/** List bounded invocation records whose mailbox append still needs repair. */ +export async function listPendingAgentInvocationMailboxAppends( + limit = 100, +): Promise { + const rows = await getSqlExecutor() + .db() + .select() + .from(juniorAgentInvocations) + .where(eq(juniorAgentInvocations.mailboxStatus, "pending")) + .orderBy(asc(juniorAgentInvocations.createdAt)) + .limit(limit); + return rows.map(invocationFromRow); +} + +/** Record that the invocation's idempotent mailbox append completed. */ +export async function markAgentInvocationMailboxAppended( + invocationId: string, + nowMs = Date.now(), +): Promise { + await getSqlExecutor() + .db() + .update(juniorAgentInvocations) + .set({ mailboxStatus: "appended", updatedAt: new Date(nowMs) }) + .where(eq(juniorAgentInvocations.invocationId, invocationId)); +} + +/** Mark one non-terminal invocation as actively executing. */ +export async function markAgentInvocationRunning( + invocationId: string, + nowMs = Date.now(), +): Promise { + await getSqlExecutor() + .db() + .update(juniorAgentInvocations) + .set({ status: "running", updatedAt: new Date(nowMs) }) + .where( + and( + eq(juniorAgentInvocations.invocationId, invocationId), + inArray( + juniorAgentInvocations.status, + NON_TERMINAL_AGENT_INVOCATION_STATUSES, + ), + ), + ); + return await getAgentInvocation(invocationId); +} + +/** Mark one invocation as waiting for another shared execution slice. */ +export async function markAgentInvocationAwaitingResume( + invocationId: string, + nowMs = Date.now(), +): Promise { + await getSqlExecutor() + .db() + .update(juniorAgentInvocations) + .set({ status: "awaiting_resume", updatedAt: new Date(nowMs) }) + .where( + and( + eq(juniorAgentInvocations.invocationId, invocationId), + inArray( + juniorAgentInvocations.status, + NON_TERMINAL_AGENT_INVOCATION_STATUSES, + ), + ), + ); +} + +/** Persist one terminal invocation result without overwriting an earlier result. */ +export async function completeAgentInvocation( + args: + | { + invocationId: string; + result: string; + status: "completed"; + nowMs?: number; + } + | { + errorMessage: string; + invocationId: string; + status: "blocked" | "failed"; + nowMs?: number; + }, +): Promise { + const nowMs = args.nowMs ?? Date.now(); + await getSqlExecutor() + .db() + .update(juniorAgentInvocations) + .set({ + status: args.status, + result: args.status === "completed" ? args.result : null, + errorMessage: args.status === "completed" ? null : args.errorMessage, + terminalAt: new Date(nowMs), + updatedAt: new Date(nowMs), + }) + .where( + and( + eq(juniorAgentInvocations.invocationId, args.invocationId), + inArray( + juniorAgentInvocations.status, + NON_TERMINAL_AGENT_INVOCATION_STATUSES, + ), + ), + ); + return await getAgentInvocation(args.invocationId); +} + +/** Return whether an invocation already owns its immutable terminal result. */ +export function isTerminalAgentInvocation( + invocation: AgentInvocation, +): boolean { + return TERMINAL_AGENT_INVOCATION_STATUSES.includes( + invocation.status as (typeof TERMINAL_AGENT_INVOCATION_STATUSES)[number], + ); +} diff --git a/packages/junior/src/chat/agent-invocations/types.ts b/packages/junior/src/chat/agent-invocations/types.ts new file mode 100644 index 000000000..3573cf6f6 --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/types.ts @@ -0,0 +1,89 @@ +import { + actorSchema, + destinationSchema, + sourceSchema, +} from "@sentry/junior-plugin-api"; +import { z } from "zod"; +import { credentialContextSchema } from "@/chat/credentials/context"; +import { TURN_REASONING_LEVELS } from "@/chat/reasoning-level"; +import { + AGENT_INVOCATION_MAILBOX_STATUSES, + AGENT_INVOCATION_STATUSES, +} from "@/db/schema/agent-invocations"; + +const exactStringSchema = z + .string() + .min(1) + .refine((value) => value === value.trim()); + +export const agentNameSchema = exactStringSchema.max(64); + +const agentInvocationMailboxStatusSchema = z.enum( + AGENT_INVOCATION_MAILBOX_STATUSES, +); + +export const agentBindingSchema = z + .object({ + childConversationId: exactStringSchema, + name: agentNameSchema, + parentConversationId: exactStringSchema, + reasoningLevel: z.enum(TURN_REASONING_LEVELS).optional(), + }) + .strict(); + +const agentInvocationBaseSchema = z + .object({ + actor: actorSchema, + agentName: agentNameSchema.optional(), + childConversationId: exactStringSchema, + createdAtMs: z.number().finite(), + credentialContext: credentialContextSchema.optional(), + destination: destinationSchema, + destinationVisibility: z.enum(["public", "private"]).optional(), + input: exactStringSchema, + invocationId: exactStringSchema, + mailboxStatus: agentInvocationMailboxStatusSchema, + parentConversationId: exactStringSchema, + reasoningLevel: z.enum(TURN_REASONING_LEVELS).optional(), + source: sourceSchema, + updatedAtMs: z.number().finite(), + }) + .strict(); + +export const agentInvocationSchema = z.discriminatedUnion("status", [ + agentInvocationBaseSchema.extend({ + status: z.enum(["pending", "running", "awaiting_resume"]), + }), + agentInvocationBaseSchema.extend({ + result: z.string(), + status: z.literal("completed"), + terminalAtMs: z.number().finite(), + }), + agentInvocationBaseSchema.extend({ + errorMessage: z.string(), + status: z.enum(["blocked", "failed"]), + terminalAtMs: z.number().finite(), + }), +]); + +export const createAgentInvocationSchema = z + .object({ + actor: actorSchema, + agentName: agentNameSchema.optional(), + credentialContext: credentialContextSchema.optional(), + destination: destinationSchema, + destinationVisibility: z.enum(["public", "private"]).optional(), + idempotencyKey: exactStringSchema, + input: exactStringSchema, + parentConversationId: exactStringSchema, + reasoningLevel: z.enum(TURN_REASONING_LEVELS).optional(), + source: sourceSchema, + }) + .strict(); + +export type AgentBinding = z.output; +export type AgentInvocation = z.output; +export type AgentInvocationStatus = (typeof AGENT_INVOCATION_STATUSES)[number]; +export type CreateAgentInvocationInput = z.input< + typeof createAgentInvocationSchema +>; diff --git a/packages/junior/src/chat/agent-invocations/work.ts b/packages/junior/src/chat/agent-invocations/work.ts new file mode 100644 index 000000000..f628ac711 --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/work.ts @@ -0,0 +1,553 @@ +import type { StateAdapter } from "chat"; +import { z } from "zod"; +import type { ConversationStore } from "@/chat/conversations/store"; +import { openConversationProjection } from "@/chat/conversations/projection"; +import { ConversationTurnLifecycleService } from "@/chat/conversations/turn-lifecycle"; +import { getConversationEventStore } from "@/chat/db"; +import type { AgentRunner } from "@/chat/runtime/agent-runner"; +import { + getPersistedSandboxState, + getPersistedThreadState, + persistThreadStateById, +} from "@/chat/runtime/thread-state"; +import { coerceThreadArtifactsState } from "@/chat/state/artifacts"; +import type { ThreadArtifactsState } from "@/chat/state/artifacts"; +import { getAgentTurnSessionRecord } from "@/chat/state/turn-session"; +import { + getConversationTurnBoundaryError, + isTurnInputCommitLostError, + TurnInputCommitLostError, +} from "@/chat/runtime/turn"; +import { + persistCompletedSessionRecord, + persistYieldSessionRecord, +} from "@/chat/services/turn-session-record"; +import { AuthorizationFlowDisabledError } from "@/chat/services/auth-pause"; +import { PluginCredentialFailureError } from "@/chat/services/plugin-auth-orchestration"; +import { getAssistantReplyText } from "@/chat/services/assistant-reply"; +import { getTerminalAssistantMessages } from "@/chat/pi/transcript"; +import type { PiMessage } from "@/chat/pi/messages"; +import type { SandboxRef } from "@/chat/sandbox/ref"; +import { + appendAndEnqueueInboundMessage, + type InboundMessage, +} from "@/chat/task-execution/store"; +import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; +import type { + ConversationWorkerContext, + ConversationWorkerResult, +} from "@/chat/task-execution/worker"; +import { + completeAgentInvocation, + createAgentInvocation, + getActiveAgentInvocationForConversation, + getAgentInvocation, + getAgentInvocationMessageId, + getAgentInvocationTurnId, + isTerminalAgentInvocation, + markAgentInvocationAwaitingResume, + markAgentInvocationMailboxAppended, + markAgentInvocationRunning, +} from "./store"; +import type { AgentInvocation, CreateAgentInvocationInput } from "./types"; + +const agentInvocationMailboxMetadataSchema = z + .object({ + agentInvocationId: z.string().min(1), + kind: z.literal("agent_invocation"), + }) + .strict(); + +type EnqueueOptions = { + conversationStore?: ConversationStore; + nowMs?: number; + queue: ConversationWorkQueue; + state?: StateAdapter; +}; + +/** Build destinationless mailbox work that references one durable invocation. */ +export function buildAgentInvocationInboundMessage( + invocation: AgentInvocation, + nowMs = Date.now(), +): InboundMessage { + return { + conversationId: invocation.childConversationId, + createdAtMs: invocation.createdAtMs, + delivery: "defer", + inboundMessageId: getAgentInvocationMessageId(invocation.invocationId), + input: { + authorId: invocation.invocationId, + text: "[agent invocation]", + metadata: { + agentInvocationId: invocation.invocationId, + kind: "agent_invocation", + }, + }, + receivedAtMs: nowMs, + source: "internal", + }; +} + +/** Append one invocation to its child mailbox and send the normal queue wake. */ +export async function enqueueAgentInvocation( + invocation: AgentInvocation, + options: EnqueueOptions, +): Promise { + if (invocation.mailboxStatus === "appended") { + return; + } + const nowMs = options.nowMs ?? Date.now(); + await appendAndEnqueueInboundMessage({ + message: buildAgentInvocationInboundMessage(invocation, nowMs), + conversationStore: options.conversationStore, + nowMs, + queue: options.queue, + state: options.state, + }); + await markAgentInvocationMailboxAppended(invocation.invocationId, nowMs); +} + +/** Create or replay an invocation, then durably schedule its child work. */ +export async function createAndEnqueueAgentInvocation( + input: CreateAgentInvocationInput, + options: EnqueueOptions, +): Promise { + const invocation = await createAgentInvocation(input, options.nowMs); + await enqueueAgentInvocation(invocation, options); + return (await getAgentInvocation(invocation.invocationId)) ?? invocation; +} + +/** Require one invocation metadata reference for one leased mailbox attempt. */ +function invocationIdFromMessages( + messages: readonly InboundMessage[], +): string | undefined { + if (messages.length === 0) { + return undefined; + } + const parsed = messages.map((message) => + agentInvocationMailboxMetadataSchema.safeParse(message.input.metadata), + ); + if (parsed.every((result) => !result.success)) { + return undefined; + } + if (parsed.some((result) => !result.success)) { + throw new Error( + "Conversation mailbox mixes agent invocation and other input", + ); + } + const invocationIds = new Set( + parsed.map((result) => { + if (!result.success) { + throw new Error("Agent invocation mailbox metadata failed validation"); + } + return result.data.agentInvocationId; + }), + ); + if (invocationIds.size !== 1) { + throw new Error( + "Conversation mailbox contains multiple agent invocations in one attempt", + ); + } + return invocationIds.values().next().value; +} + +/** Resolve invocation execution from mailbox input or durable active state. */ +export async function resolveAgentInvocationId( + context: ConversationWorkerContext, +): Promise { + const mailboxInvocationId = invocationIdFromMessages( + context.attempt.messages, + ); + if (mailboxInvocationId) { + return mailboxInvocationId; + } + if (context.attempt.messages.length > 0) { + return undefined; + } + return (await getActiveAgentInvocationForConversation(context.conversationId)) + ?.invocationId; +} + +/** Project the terminal invocation into the idempotent turn lifecycle. */ +async function persistTerminalLifecycle( + invocation: AgentInvocation, +): Promise { + const lifecycle = new ConversationTurnLifecycleService( + getConversationEventStore(), + ); + const common = { + conversationId: invocation.childConversationId, + createdAtMs: + "terminalAtMs" in invocation + ? invocation.terminalAtMs + : invocation.updatedAtMs, + turnId: getAgentInvocationTurnId(invocation.invocationId), + }; + if (invocation.status === "completed") { + await lifecycle.complete({ + ...common, + outcome: invocation.result?.trim() ? "success" : "no_reply", + }); + } else if ( + invocation.status === "blocked" || + invocation.status === "failed" + ) { + await lifecycle.fail({ + ...common, + failureCode: "model_execution_failed", + }); + } +} + +/** Recover a terminal invocation from its authoritative completed session. */ +async function projectTerminalSession( + invocation: AgentInvocation, +): Promise { + const session = await getAgentTurnSessionRecord( + invocation.childConversationId, + getAgentInvocationTurnId(invocation.invocationId), + ); + if (!session) { + return undefined; + } + if (session.state === "awaiting_resume" || session.state === "running") { + return undefined; + } + const result = getTerminalAssistantMessages(session.piMessages) + .map(getAssistantReplyText) + .filter((text): text is string => text !== undefined) + .join("\n\n"); + const failed = session.state !== "completed" || Boolean(session.errorMessage); + return await completeAgentInvocation({ + invocationId: invocation.invocationId, + ...(failed + ? { + errorMessage: session.errorMessage ?? "Agent invocation failed", + status: "failed" as const, + } + : { + result, + status: "completed" as const, + }), + }); +} + +/** Re-park a stranded running session at its latest durable safe boundary. */ +async function recoverRunningSession( + invocation: AgentInvocation, +): Promise { + const session = await getAgentTurnSessionRecord( + invocation.childConversationId, + getAgentInvocationTurnId(invocation.invocationId), + ); + if (!session || session.state !== "running") { + return; + } + if (!session.modelId) { + throw new Error( + `Running agent invocation session is missing its model for ${invocation.invocationId}`, + ); + } + const parked = await persistYieldSessionRecord({ + conversationId: invocation.childConversationId, + currentSliceId: session.sliceId, + destination: session.destination, + errorMessage: "Recovered running agent invocation after worker loss", + messages: session.piMessages, + modelId: session.modelId, + actor: session.actor, + reasoningLevel: session.reasoningLevel, + sessionId: session.sessionId, + source: session.source, + surface: session.surface, + }); + if (!parked) { + throw new Error( + `Running agent invocation had no resumable boundary for ${invocation.invocationId}`, + ); + } + await markAgentInvocationAwaitingResume(invocation.invocationId); +} + +/** Classify authorization failures that terminally block background work. */ +function blockingInvocationError( + error: unknown, +): AuthorizationFlowDisabledError | PluginCredentialFailureError | undefined { + const cause = getConversationTurnBoundaryError(error)?.cause ?? error; + return cause instanceof AuthorizationFlowDisabledError || + cause instanceof PluginCredentialFailureError + ? cause + : undefined; +} + +function isInvocationInputCommitLost(error: unknown): boolean { + const cause = getConversationTurnBoundaryError(error)?.cause ?? error; + return isTurnInputCommitLostError(cause); +} + +/** Build the invocation consumer that advances work through the shared runner. */ +export function createAgentInvocationWorker(options: { + agentRunner: AgentRunner; +}) { + return async ( + context: ConversationWorkerContext, + invocationId: string, + ): Promise => { + let invocation = await getAgentInvocation(invocationId); + if (!invocation) { + throw new Error(`Agent invocation is missing for ${invocationId}`); + } + + let acknowledged = context.attempt.messages.length === 0; + const acknowledge = async (): Promise => { + if (acknowledged) { + return; + } + try { + await context.attempt.ack(); + } catch { + throw new TurnInputCommitLostError( + `Conversation work lease lost before invocation inbox ack for ${context.conversationId}`, + ); + } + acknowledged = true; + }; + + const turnId = getAgentInvocationTurnId(invocation.invocationId); + const lifecycle = new ConversationTurnLifecycleService( + getConversationEventStore(), + ); + let artifacts: ThreadArtifactsState; + let sandboxRef: SandboxRef | undefined; + let history: PiMessage[]; + try { + if (invocation.childConversationId !== context.conversationId) { + throw new Error( + `Agent invocation ${invocationId} belongs to ${invocation.childConversationId}, not ${context.conversationId}`, + ); + } + if (context.destination) { + throw new Error( + `Agent invocation conversation ${context.conversationId} must not own a provider destination`, + ); + } + if (isTerminalAgentInvocation(invocation)) { + await persistTerminalLifecycle(invocation); + await acknowledge(); + return { status: "completed" }; + } + const projected = await projectTerminalSession(invocation); + if (projected && isTerminalAgentInvocation(projected)) { + await persistTerminalLifecycle(projected); + await acknowledge(); + return { status: "completed" }; + } + await recoverRunningSession(invocation); + invocation = + (await markAgentInvocationRunning(invocation.invocationId)) ?? + invocation; + await lifecycle.start({ + conversationId: invocation.childConversationId, + createdAtMs: invocation.createdAtMs, + inputMessageIds: [getAgentInvocationMessageId(invocation.invocationId)], + surface: "internal", + turnId, + }); + const [persisted, projection] = await Promise.all([ + getPersistedThreadState(invocation.childConversationId), + openConversationProjection({ + conversationId: invocation.childConversationId, + }), + ]); + artifacts = coerceThreadArtifactsState(persisted); + sandboxRef = getPersistedSandboxState(persisted); + history = projection.messages; + } catch (error) { + if (isInvocationInputCommitLost(error)) { + return { status: "lost_lease" }; + } + if (!context.attempt.isFinalAttempt) { + throw error; + } + const terminal = await completeAgentInvocation({ + invocationId: invocation.invocationId, + errorMessage: + error instanceof Error ? error.message : "Agent invocation failed", + status: "failed", + }); + if (terminal) { + await persistTerminalLifecycle(terminal); + } + await acknowledge(); + return { status: "completed" }; + } + + let outcome; + try { + outcome = await options.agentRunner.run({ + conversationId: invocation.childConversationId, + turnId, + runId: invocation.invocationId, + input: { + messageText: invocation.input, + piMessages: history, + }, + routing: { + actor: invocation.actor, + credentialContext: invocation.credentialContext, + destination: invocation.destination, + destinationVisibility: invocation.destinationVisibility, + source: invocation.source, + surface: "internal", + }, + policy: { + agentSpawning: "disabled", + authorizationFlowMode: "disabled", + reasoningLevel: invocation.reasoningLevel, + }, + state: { + artifactState: artifacts, + sandboxRef, + }, + durability: { + onInputCommitted: acknowledge, + shouldYield: context.shouldYield, + onArtifactStateUpdated: async (nextArtifacts) => { + artifacts = nextArtifacts; + await persistThreadStateById(invocation.childConversationId, { + artifacts, + sandboxRef, + }); + }, + onSandboxRefChanged: async (nextSandboxRef) => { + sandboxRef = nextSandboxRef; + await persistThreadStateById(invocation.childConversationId, { + artifacts, + sandboxRef, + }); + }, + }, + }); + } catch (error) { + if (isInvocationInputCommitLost(error)) { + return { status: "lost_lease" }; + } + const blocking = blockingInvocationError(error); + if (blocking) { + const terminal = await completeAgentInvocation({ + invocationId: invocation.invocationId, + errorMessage: blocking.message, + status: "blocked", + }); + if (terminal) { + await persistTerminalLifecycle(terminal); + } + await acknowledge(); + return { status: "completed" }; + } + if (!context.attempt.isFinalAttempt) { + throw error; + } + const terminal = await completeAgentInvocation({ + invocationId: invocation.invocationId, + errorMessage: + error instanceof Error ? error.message : "Agent invocation failed", + status: "failed", + }); + if (terminal) { + await persistTerminalLifecycle(terminal); + } + await acknowledge(); + return { status: "completed" }; + } + + if (outcome.status === "suspended") { + await markAgentInvocationAwaitingResume(invocation.invocationId); + return { status: "yielded" }; + } + if (outcome.status === "awaiting_auth") { + const terminal = await completeAgentInvocation({ + invocationId: invocation.invocationId, + errorMessage: `Agent invocation requires ${outcome.providerDisplayName} authorization`, + status: "blocked", + }); + if (terminal) { + await persistTerminalLifecycle(terminal); + } + await acknowledge(); + return { status: "completed" }; + } + + const result = outcome.result; + const failed = result.diagnostics.outcome !== "success"; + await persistThreadStateById(invocation.childConversationId, { + artifacts: result.artifactStatePatch + ? { ...artifacts, ...result.artifactStatePatch } + : artifacts, + sandboxRef: result.sandboxRef ?? sandboxRef, + }); + if (result.piMessages?.length) { + await persistCompletedSessionRecord({ + conversationId: invocation.childConversationId, + currentDurationMs: result.diagnostics.durationMs, + currentUsage: result.diagnostics.usage, + destination: invocation.destination, + destinationVisibility: invocation.destinationVisibility, + ...(failed + ? { + errorMessage: + result.diagnostics.errorMessage ?? "Agent invocation failed", + } + : {}), + sessionId: turnId, + allMessages: result.piMessages, + modelId: result.diagnostics.modelId, + actor: invocation.actor, + reasoningLevel: result.diagnostics.reasoningLevel, + source: invocation.source, + surface: "internal", + }); + } + const terminal = await completeAgentInvocation({ + invocationId: invocation.invocationId, + ...(failed + ? { + errorMessage: + result.diagnostics.errorMessage ?? "Agent invocation failed", + status: "failed" as const, + } + : { + result: result.text, + status: "completed" as const, + }), + }); + if (!terminal) { + throw new Error( + `Agent invocation disappeared during completion for ${invocation.invocationId}`, + ); + } + await persistTerminalLifecycle(terminal); + await acknowledge(); + return { status: "completed" }; + }; +} + +/** Route invocation-backed work before falling through to existing consumers. */ +export function routeAgentInvocationWork(options: { + fallbackWorker: ( + context: ConversationWorkerContext, + ) => Promise; + invocationWorker: ( + context: ConversationWorkerContext, + invocationId: string, + ) => Promise; +}) { + return async ( + context: ConversationWorkerContext, + ): Promise => { + const invocationId = await resolveAgentInvocationId(context); + return invocationId + ? await options.invocationWorker(context, invocationId) + : await options.fallbackWorker(context); + }; +} diff --git a/packages/junior/src/chat/agent/request.ts b/packages/junior/src/chat/agent/request.ts index f27609daf..8ad085518 100644 --- a/packages/junior/src/chat/agent/request.ts +++ b/packages/junior/src/chat/agent/request.ts @@ -62,6 +62,24 @@ export interface AgentRunSteeringMessage { userAttachments?: AgentRunAttachment[]; } +/** Model-safe input for one asynchronously delegated child-agent task. */ +export type SpawnAgentInput = { + name?: string; + reasoningLevel?: TurnReasoningLevel; + task: string; +}; + +/** Handle returned after a child-agent task is durably scheduled. */ +export type SpawnAgentResult = { + invocationId: string; +}; + +/** Runtime-bound child-agent capability exposed to model-facing tool wiring. */ +export type SpawnAgent = ( + input: SpawnAgentInput, + options: { signal?: AbortSignal; toolCallId: string }, +) => Promise; + /** Carries the user-visible content and prior transcript for one agent-run slice. */ export interface AgentRunInput { actor?: AgentRunInstructionActor; @@ -101,6 +119,8 @@ export interface AgentRunRouting { /** Carries execution limits and dependency overrides for one run slice. */ export interface AgentRunPolicy { + /** Disable child-agent spawning for runs that are already delegated work. */ + agentSpawning?: "disabled"; /** Absolute wall-clock deadline for this host request, in milliseconds. */ turnDeadlineAtMs?: number; /** Cancels provider work when the owning host request is abandoned. */ @@ -165,6 +185,8 @@ export class RetryableDeliveryError extends Error { /** Carries durable-worker ports that commit or update resumable run state. */ export interface AgentRunDurability { + /** Schedule delegated work with authority bound by the active parent run. */ + spawnAgent?: SpawnAgent; onInputCommitted?: () => void | Promise; /** Return true when the durable worker should pause at the next Pi boundary. */ shouldYield?: () => boolean; @@ -228,15 +250,20 @@ export function assertRunRoutingConsistency( if (source.teamId !== destination.teamId) { throw new TypeError("Slack source and destination teams do not match"); } - } else if ( - source.platform === "local" && - destination.platform === "local" && - (source.conversationId !== request.conversationId || - destination.conversationId !== request.conversationId) - ) { - throw new TypeError( - "Local source, destination, and run conversation IDs do not match", - ); + } else if (source.platform === "local" && destination.platform === "local") { + if (source.conversationId !== destination.conversationId) { + throw new TypeError( + "Local source and destination conversation IDs do not match", + ); + } + if ( + request.routing.surface !== "internal" && + destination.conversationId !== request.conversationId + ) { + throw new TypeError( + "Local source, destination, and run conversation IDs do not match", + ); + } } const actor = request.routing.dispatch?.actor ?? request.routing.actor; diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index 2c38e469a..33c0a110d 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -265,6 +265,9 @@ export async function wireAgentTools( workspace: agentSandbox.workspace, supportsImageInput: args.supportsImageInput, surface: args.surface, + ...(args.durability.spawnAgent + ? { spawnAgent: args.durability.spawnAgent } + : {}), ...(args.requestHandoff ? { handoff: args.requestHandoff } : {}), }; const toolDestination = toolInvocationDestination(args.routing); diff --git a/packages/junior/src/chat/app/production.ts b/packages/junior/src/chat/app/production.ts index 50fca3edb..2c7de029b 100644 --- a/packages/junior/src/chat/app/production.ts +++ b/packages/junior/src/chat/app/production.ts @@ -23,6 +23,10 @@ import { createAgentDispatchWorkRouter, createAgentDispatchConversationWorker, } from "@/chat/agent-dispatch/work"; +import { + createAgentInvocationWorker, + routeAgentInvocationWork, +} from "@/chat/agent-invocations/work"; import { getDispatchConversationId, getDispatchInputMessageIds, @@ -153,12 +157,18 @@ export function createProductionConversationWorkOptions(options: { }, runTurn: runtime.runDispatchTurn, }); + const providerWorker = createAgentDispatchWorkRouter({ + dispatchWorker, + fallbackWorker: slackWorker, + }); return { conversationStore, queue, - run: createAgentDispatchWorkRouter({ - dispatchWorker, - fallbackWorker: slackWorker, + run: routeAgentInvocationWork({ + invocationWorker: createAgentInvocationWorker({ + agentRunner, + }), + fallbackWorker: providerWorker, }), }; } diff --git a/packages/junior/src/chat/app/services.ts b/packages/junior/src/chat/app/services.ts index 651120d86..e452f1ba1 100644 --- a/packages/junior/src/chat/app/services.ts +++ b/packages/junior/src/chat/app/services.ts @@ -33,6 +33,8 @@ import { import { createAgentRunner } from "@/chat/runtime/agent-runner"; import { ConversationTurnLifecycleService } from "@/chat/conversations/turn-lifecycle"; import { getConversationEventStore } from "@/chat/db"; +import { bindSpawnAgent } from "@/chat/agent-invocations/spawn"; +import { getVercelConversationWorkQueue } from "@/chat/task-execution/vercel-queue"; export interface JuniorRuntimeServices { conversationMemory: ConversationMemoryService; @@ -71,6 +73,13 @@ export function createJuniorRuntimeServices( downloadFile: overrides.visionContext?.downloadFile ?? downloadPrivateSlackFile, }); + const agentRunner = + overrides.replyExecutor?.agentRunner ?? + createAgentRunner(executeAgentRunImpl, { + bindSpawnAgent: (request) => + bindSpawnAgent(request, { queue: getVercelConversationWorkQueue }), + tracePropagation: overrides.sandbox?.tracePropagation, + }); return { conversationMemory, @@ -78,11 +87,7 @@ export function createJuniorRuntimeServices( replyExecutor: { contextCompactor: overrides.replyExecutor?.contextCompactor ?? contextCompactor, - agentRunner: - overrides.replyExecutor?.agentRunner ?? - createAgentRunner(executeAgentRunImpl, { - tracePropagation: overrides.sandbox?.tracePropagation, - }), + agentRunner, getAwaitingAgentContinueRequest: overrides.replyExecutor?.getAwaitingAgentContinueRequest ?? getAwaitingAgentContinueRequest, diff --git a/packages/junior/src/chat/conversations/README.md b/packages/junior/src/chat/conversations/README.md index db342654b..0936cc68f 100644 --- a/packages/junior/src/chat/conversations/README.md +++ b/packages/junior/src/chat/conversations/README.md @@ -93,9 +93,10 @@ to `unknown`. ## Visibility And Retention Destination visibility is the privacy authority. Messages, agent-history -items, child conversations, and plugin projections inherit it. Retention +items, child conversations, agent invocations, and plugin projections inherit +it. Retention distinguishes expired content from redacted content and purges the complete -child tree. +child tree, including delegated input and terminal results. Every conversation row carries its owning `root_conversation_id`. Roots self-reference; descendants copy the root from their immediate parent when diff --git a/packages/junior/src/chat/conversations/sql/purge.ts b/packages/junior/src/chat/conversations/sql/purge.ts index 47b7d813a..719587109 100644 --- a/packages/junior/src/chat/conversations/sql/purge.ts +++ b/packages/junior/src/chat/conversations/sql/purge.ts @@ -1,13 +1,16 @@ -import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import type { JuniorSqlDatabase } from "@/db/db"; import type { JuniorDestinationVisibility } from "@/db/schema/destinations"; import { juniorConversationEvents, juniorConversations, juniorDestinations, + juniorAgentBindings, + juniorAgentInvocations, } from "@/db/schema"; import { withConversationEventLock } from "./event-lock"; import { resolveRootVisibility } from "./privacy"; +import { withConversationMutationLock } from "./store"; /** An expired root conversation selected for purge, with its resolved visibility. */ export interface ExpiredRoot { @@ -104,6 +107,16 @@ export async function selectExpiredRoots( select 1 from junior_conversation_events events where events.conversation_id = tree.conversation_id ) + or exists ( + select 1 from junior_agent_invocations invocations + where invocations.parent_conversation_id = tree.conversation_id + or invocations.child_conversation_id = tree.conversation_id + ) + or exists ( + select 1 from junior_agent_bindings bindings + where bindings.parent_conversation_id = tree.conversation_id + or bindings.child_conversation_id = tree.conversation_id + ) or ( ${juniorDestinations.visibility} is distinct from 'public' and exists ( @@ -163,110 +176,132 @@ export async function purgeConversationTree( retention?: { privateWindowMs: number; publicWindowMs: number }; }, ): Promise { - return await executor.transaction(async () => { - const initialRoots = await executor - .db() - .select({ - conversationId: juniorConversations.conversationId, - parentConversationId: juniorConversations.parentConversationId, - }) - .from(juniorConversations) - .where(eq(juniorConversations.conversationId, args.rootConversationId)); - const initialRoot = initialRoots[0]; - if ( - !initialRoot || - (args.retention && initialRoot.parentConversationId !== null) - ) { - return { purged: false, conversations: 0 }; - } - const tree = await discoverConversationTree(executor, initialRoot); + return await withConversationMutationLock( + executor, + args.rootConversationId, + async () => { + const initialRoots = await executor + .db() + .select({ + conversationId: juniorConversations.conversationId, + parentConversationId: juniorConversations.parentConversationId, + }) + .from(juniorConversations) + .where(eq(juniorConversations.conversationId, args.rootConversationId)); + const initialRoot = initialRoots[0]; + if ( + !initialRoot || + (args.retention && initialRoot.parentConversationId !== null) + ) { + return { purged: false, conversations: 0 }; + } + const tree = await discoverConversationTree(executor, initialRoot); - return await withConversationEventLock( - executor, - args.rootConversationId, - async () => { - // Parent links are immutable and child creation is migration-only, so - // one unlocked traversal is authoritative. Acquire every event lock - // before row locks so a child writer can finish without deadlocking on - // its parent relationship. - for (const conversation of tree.slice(1)) { - await withConversationEventLock( - executor, - conversation.conversationId, - async () => undefined, - ); - } + return await withConversationEventLock( + executor, + args.rootConversationId, + async () => { + // Parent links are immutable, and the root mutation lock prevents live + // child creation after discovery. Acquire every event lock before row + // locks so a child writer can finish without deadlocking on its parent + // relationship. + for (const conversation of tree.slice(1)) { + await withConversationEventLock( + executor, + conversation.conversationId, + async () => undefined, + ); + } - const roots = await executor - .db() - .select({ - conversationId: juniorConversations.conversationId, - destinationId: juniorConversations.destinationId, - parentConversationId: juniorConversations.parentConversationId, - }) - .from(juniorConversations) - .where( - eq(juniorConversations.conversationId, args.rootConversationId), - ) - .for("update"); - const root = roots[0]; - if (!root || (args.retention && root.parentConversationId !== null)) { - return { purged: false, conversations: 0 }; - } - const resolvedScrubMetadata = args.scrubMetadataFromRootVisibility - ? (await resolveRootVisibility(executor, args.rootConversationId)) - .visibility !== "public" - : args.scrubMetadata; - const destinations = root.destinationId - ? await executor - .db() - .select({ visibility: juniorDestinations.visibility }) - .from(juniorDestinations) - .where(eq(juniorDestinations.id, root.destinationId)) - .for("share") - : []; - const isPublic = destinations[0]?.visibility === "public"; - const ids = tree.map((conversation) => conversation.conversationId); - if (args.retention) { - const currentActivity = await executor + const roots = await executor .db() .select({ conversationId: juniorConversations.conversationId, - lastActivityAt: juniorConversations.lastActivityAt, + destinationId: juniorConversations.destinationId, + parentConversationId: juniorConversations.parentConversationId, }) .from(juniorConversations) - .where(inArray(juniorConversations.conversationId, ids)); - if (currentActivity.length !== ids.length) { + .where( + eq(juniorConversations.conversationId, args.rootConversationId), + ) + .for("update"); + const root = roots[0]; + if (!root || (args.retention && root.parentConversationId !== null)) { return { purged: false, conversations: 0 }; } - const windowMs = isPublic - ? args.retention.publicWindowMs - : args.retention.privateWindowMs; - const effectiveLastActivityAt = Math.max( - ...currentActivity.map((conversation) => - conversation.lastActivityAt.getTime(), - ), - ); - if (effectiveLastActivityAt >= args.nowMs - windowMs) { - return { purged: false, conversations: 0 }; + const resolvedScrubMetadata = args.scrubMetadataFromRootVisibility + ? (await resolveRootVisibility(executor, args.rootConversationId)) + .visibility !== "public" + : args.scrubMetadata; + const destinations = root.destinationId + ? await executor + .db() + .select({ visibility: juniorDestinations.visibility }) + .from(juniorDestinations) + .where(eq(juniorDestinations.id, root.destinationId)) + .for("share") + : []; + const isPublic = destinations[0]?.visibility === "public"; + const ids = tree.map((conversation) => conversation.conversationId); + if (args.retention) { + const currentActivity = await executor + .db() + .select({ + conversationId: juniorConversations.conversationId, + lastActivityAt: juniorConversations.lastActivityAt, + }) + .from(juniorConversations) + .where(inArray(juniorConversations.conversationId, ids)); + if (currentActivity.length !== ids.length) { + return { purged: false, conversations: 0 }; + } + const windowMs = isPublic + ? args.retention.publicWindowMs + : args.retention.privateWindowMs; + const effectiveLastActivityAt = Math.max( + ...currentActivity.map((conversation) => + conversation.lastActivityAt.getTime(), + ), + ); + if (effectiveLastActivityAt >= args.nowMs - windowMs) { + return { purged: false, conversations: 0 }; + } } - } - await executor - .db() - .delete(juniorConversationEvents) - .where(inArray(juniorConversationEvents.conversationId, ids)); - await executor - .db() - .update(juniorConversations) - .set({ - transcriptPurgedAt: new Date(args.nowMs), - ...((args.retention ? !isPublic : resolvedScrubMetadata) - ? { title: null, channelName: null, actor: null } - : {}), - }) - .where(inArray(juniorConversations.conversationId, ids)); - return { purged: true, conversations: ids.length }; - }, - ); - }); + await executor + .db() + .delete(juniorConversationEvents) + .where(inArray(juniorConversationEvents.conversationId, ids)); + await executor + .db() + .delete(juniorAgentInvocations) + .where( + or( + inArray(juniorAgentInvocations.parentConversationId, ids), + inArray(juniorAgentInvocations.childConversationId, ids), + ), + ); + await executor + .db() + .delete(juniorAgentBindings) + .where( + or( + inArray(juniorAgentBindings.parentConversationId, ids), + inArray(juniorAgentBindings.childConversationId, ids), + ), + ); + await executor + .db() + .update(juniorConversations) + .set({ + transcriptPurgedAt: new Date(args.nowMs), + ...((args.retention ? !isPublic : resolvedScrubMetadata) + ? { title: null, channelName: null, actor: null } + : {}), + }) + .where(inArray(juniorConversations.conversationId, ids)); + return { purged: true, conversations: ids.length }; + }, + ); + }, + ); } diff --git a/packages/junior/src/chat/conversations/sql/store.ts b/packages/junior/src/chat/conversations/sql/store.ts index 01062d26d..4385d5beb 100644 --- a/packages/junior/src/chat/conversations/sql/store.ts +++ b/packages/junior/src/chat/conversations/sql/store.ts @@ -50,6 +50,18 @@ interface DestinationUpsert { const CONVERSATION_MUTATION_LOCK_PREFIX = "junior_conversation"; +/** Serialize one conversation's durable mutations inside a SQL transaction. */ +export async function withConversationMutationLock( + executor: JuniorSqlDatabase, + conversationId: string, + callback: () => Promise, +): Promise { + return await executor.withLock( + `${CONVERSATION_MUTATION_LOCK_PREFIX}:${conversationId}`, + async () => await executor.transaction(callback), + ); +} + function now(): number { return Date.now(); } @@ -479,6 +491,58 @@ function updateConversationUsage(args: { export class SqlStore implements ConversationStore { constructor(private readonly executor: JuniorSqlDatabase) {} + async createChild(args: { + childConversationId: string; + parentConversationId: string; + nowMs?: number; + source?: ConversationSource; + }): Promise { + const nowMs = args.nowMs ?? now(); + await this.withConversationMutation(args.parentConversationId, async () => { + await this.withConversationMutation( + args.childConversationId, + async () => { + const existing = await this.get({ + conversationId: args.childConversationId, + }); + if (existing) { + if ( + existing.lineage?.parentConversationId !== + args.parentConversationId + ) { + throw new Error( + `Conversation lineage changed for ${args.childConversationId}`, + ); + } + return; + } + const parent = await this.get({ + conversationId: args.parentConversationId, + }); + if (!parent) { + throw new Error( + `Conversation parent is missing for ${args.childConversationId}`, + ); + } + if (parent.lineage) { + throw new Error("Recursive agent delegation is not enabled"); + } + const child: Conversation = { + ...emptyConversation({ + conversationId: args.childConversationId, + nowMs, + source: args.source, + }), + lineage: { + parentConversationId: args.parentConversationId, + }, + }; + await this.upsertConversation({ conversation: child }); + }, + ); + }); + } + async get(args: { conversationId: string; }): Promise { @@ -700,9 +764,10 @@ export class SqlStore implements ConversationStore { conversationId: string, callback: () => Promise, ): Promise { - return await this.executor.withLock( - `${CONVERSATION_MUTATION_LOCK_PREFIX}:${conversationId}`, - async () => await this.executor.transaction(callback), + return await withConversationMutationLock( + this.executor, + conversationId, + callback, ); } diff --git a/packages/junior/src/chat/conversations/store.ts b/packages/junior/src/chat/conversations/store.ts index c1b5d7078..19a4ecc7b 100644 --- a/packages/junior/src/chat/conversations/store.ts +++ b/packages/junior/src/chat/conversations/store.ts @@ -58,6 +58,13 @@ export interface Conversation { /** Persist and read durable conversation metadata for reporting surfaces. */ export interface ConversationStore { + /** Create one destinationless child with immutable parent lineage. */ + createChild(args: { + childConversationId: string; + parentConversationId: string; + nowMs?: number; + source?: ConversationSource; + }): Promise; get(args: { conversationId: string }): Promise; /** Read persisted visibility for one destination. Missing rows fail closed. */ getDestinationVisibility(args: { diff --git a/packages/junior/src/chat/local/README.md b/packages/junior/src/chat/local/README.md index e77d6ff5a..63981e322 100644 --- a/packages/junior/src/chat/local/README.md +++ b/packages/junior/src/chat/local/README.md @@ -24,6 +24,10 @@ provider mailbox worker. and SQL persistence are not one transaction. A process death in that interval can strand a started turn; lifecycle history does not claim otherwise. - New CLI invocations do not promise restoration of prior interactive history. +- Child-agent work uses the shared invocation mailbox worker in the CLI + process. Prompt mode drains accepted child work before exit; interactive mode + drains it when the session exits. It does not depend on a Vercel Queue dev + consumer, and a process death can still strand process-local mailbox state. - Status and diagnostics go to stderr; assistant messages go to stdout in model message order. - Local file requests use paths named by the user. The adapter does not diff --git a/packages/junior/src/chat/local/conversation-work.ts b/packages/junior/src/chat/local/conversation-work.ts new file mode 100644 index 000000000..a60377458 --- /dev/null +++ b/packages/junior/src/chat/local/conversation-work.ts @@ -0,0 +1,75 @@ +import type { + ConversationQueueMessage, + ConversationQueueSendOptions, + ConversationWorkQueue, +} from "@/chat/task-execution/queue"; + +/** + * Run local conversation work in this process and wait for every accepted wake. + * + * Wakes start on a later event-loop turn so their producer can persist the + * accepted marker first. Idempotent, delayed, and follow-up wakes remain + * tracked so CLI shutdown cannot abandon accepted child work. + */ +export function createLocalConversationWork( + processMessage: (message: ConversationQueueMessage) => Promise, +) { + const acceptedWakeIds = new Map(); + const pending = new Set>(); + let firstError: unknown; + let nextWakeId = 1; + + function schedule( + message: ConversationQueueMessage, + options?: ConversationQueueSendOptions, + ): void { + const delayMs = Math.max(0, options?.delayMs ?? 0); + let work: Promise; + work = (async () => { + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + await processMessage(message); + })() + .catch((error: unknown) => { + firstError ??= error; + }) + .finally(() => { + pending.delete(work); + }); + pending.add(work); + } + + const queue: ConversationWorkQueue = { + async send(message, options) { + const idempotencyKey = options?.idempotencyKey; + const acceptedWakeId = idempotencyKey + ? acceptedWakeIds.get(idempotencyKey) + : undefined; + if (acceptedWakeId) { + return { messageId: acceptedWakeId }; + } + const messageId = `local-conversation-work:${nextWakeId}`; + nextWakeId += 1; + if (idempotencyKey) { + acceptedWakeIds.set(idempotencyKey, messageId); + } + schedule(message, options); + return { messageId }; + }, + }; + + return { + queue, + async drain() { + while (pending.size > 0) { + await Promise.all(pending); + } + if (firstError !== undefined) { + const error = firstError; + firstError = undefined; + throw error; + } + }, + }; +} diff --git a/packages/junior/src/chat/local/runner.ts b/packages/junior/src/chat/local/runner.ts index 39833d1b3..51dec59e8 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -51,7 +51,7 @@ import { commitAcceptedReply, loadProjection, } from "@/chat/conversations/projection"; -import { getConversationEventStore } from "@/chat/db"; +import { getConversationEventStore, getConversationStore } from "@/chat/db"; import { ConversationTurnLifecycleService, type ConversationTurnLifecycle, @@ -207,6 +207,12 @@ async function runLocalAgentTurnInContext( new ConversationTurnLifecycleService(getConversationEventStore()); const now = deps.now ?? (() => Date.now()); + await getConversationStore().recordActivity({ + conversationId: input.conversationId, + destination, + nowMs: now(), + source: "local", + }); const persisted = await getPersistedThreadState(input.conversationId); const conversation = coerceThreadConversationState(persisted); await hydrateConversationMessages({ diff --git a/packages/junior/src/chat/runtime/agent-runner.ts b/packages/junior/src/chat/runtime/agent-runner.ts index 1c5b4b39c..09a44ef2e 100644 --- a/packages/junior/src/chat/runtime/agent-runner.ts +++ b/packages/junior/src/chat/runtime/agent-runner.ts @@ -1,4 +1,4 @@ -import type { AgentRunRequest } from "@/chat/agent/request"; +import type { AgentRunRequest, SpawnAgent } from "@/chat/agent/request"; import type { AgentRunOutcome } from "@/chat/runtime/agent-run-outcome"; import type { SandboxEgressTracePropagationConfig } from "@/chat/sandbox/egress/tracing"; @@ -10,21 +10,38 @@ export interface AgentRunner { /** Adapt the Pi-facing agent-run executor behind the runtime-owned runner seam. */ export function createAgentRunner( run: AgentRunner["run"], - options?: { tracePropagation?: SandboxEgressTracePropagationConfig }, + options?: { + bindSpawnAgent?: (request: AgentRunRequest) => SpawnAgent | undefined; + tracePropagation?: SandboxEgressTracePropagationConfig; + }, ): AgentRunner { const tracePropagation = options?.tracePropagation; - if (!tracePropagation) { + const bindSpawnAgent = options?.bindSpawnAgent; + if (!tracePropagation && !bindSpawnAgent) { return { run }; } return { - run: async (request) => - await run({ + run: async (request) => { + const spawnAgent = + bindSpawnAgent && request.policy?.agentSpawning !== "disabled" + ? bindSpawnAgent(request) + : undefined; + return await run({ ...request, policy: { ...request.policy, sandboxTracePropagation: request.policy?.sandboxTracePropagation ?? tracePropagation, }, - }), + ...(spawnAgent + ? { + durability: { + ...request.durability, + spawnAgent, + }, + } + : {}), + }); + }, }; } diff --git a/packages/junior/src/chat/state/turn-session.ts b/packages/junior/src/chat/state/turn-session.ts index 70b52ffe3..960dec001 100644 --- a/packages/junior/src/chat/state/turn-session.ts +++ b/packages/junior/src/chat/state/turn-session.ts @@ -262,25 +262,31 @@ async function recordConversationActivityMetadata(args: { summary: AgentTurnSessionSummary; }): Promise { const conversationStore = args.conversationStore ?? getConversationStore(); - const source = - args.summary.destination?.platform === "local" + const conversation = await conversationStore.get({ + conversationId: args.summary.conversationId, + }); + const isChild = Boolean(conversation?.lineage); + const destination = isChild ? undefined : args.summary.destination; + const source = isChild + ? "internal" + : destination?.platform === "local" ? "local" : args.summary.surface; await conversationStore.recordActivity({ activityAtMs: args.summary.updatedAtMs, channelName: args.summary.channelName, conversationId: args.summary.conversationId, - destination: args.summary.destination, + destination, nowMs: args.nowMs, actor: sessionLogActor(args.summary.actor), source, - visibility: args.destinationVisibility, + visibility: isChild ? undefined : args.destinationVisibility, }); await conversationStore.recordExecution({ channelName: args.summary.channelName, conversationId: args.summary.conversationId, createdAtMs: args.summary.startedAtMs, - destination: args.summary.destination, + destination, execution: conversationExecutionFromSummary(args.summary), lastActivityAtMs: args.summary.updatedAtMs, metrics: { @@ -292,7 +298,7 @@ async function recordConversationActivityMetadata(args: { actor: sessionLogActor(args.summary.actor), source, updatedAtMs: args.nowMs, - visibility: args.destinationVisibility, + visibility: isChild ? undefined : args.destinationVisibility, }); } diff --git a/packages/junior/src/chat/task-execution/README.md b/packages/junior/src/chat/task-execution/README.md index f3920bbf5..b74d97447 100644 --- a/packages/junior/src/chat/task-execution/README.md +++ b/packages/junior/src/chat/task-execution/README.md @@ -11,7 +11,9 @@ the completed result. - A conversation mailbox contains normalized pending work with a durable delivery mode: `interrupt` or `defer`. - A queue payload identifies the conversation to wake; persisted conversation - work owns destination and delivery. + work owns delivery. Provider conversations own their destination, while + destinationless child work resolves bounded execution authority from its + durable agent invocation. - A lease grants one worker temporary execution ownership. - Dispatch projection updates take a short dispatch lock only while the conversation lease is already held. They never wait for conversation work, @@ -29,12 +31,15 @@ require a valid delivery value and reject invalid pending work. 1. Ingress appends mailbox work before sending a queue nudge. 2. The worker validates the queue callback and acquires the conversation lease. -3. While it owns the lease, the worker reloads durable state and runs the next +3. While it owns the lease, the worker reloads durable state and routes the next work: `interrupt` mailbox delivery first, then a paused turn, then `defer` mailbox delivery. Each iteration gets a fresh mailbox delivery attempt. New dispatch input identifies its dispatch in mailbox metadata; later slices restore that identifier from the turn session rather than queue payloads, conversation source, or conversation-id conventions. + Agent invocation input follows the same rule: the mailbox carries its + invocation ID, and an empty resume attempt resolves the active invocation + from SQL. 4. Runtime advances the turn until completion, auth pause, cooperative yield, or terminal failure, delivering and recording completed tool-free assistant messages as it advances. A requested turn resume is durable state, not an @@ -45,8 +50,8 @@ require a valid delivery value and reject invalid pending work. current worker observes it on its next state reload. 6. Before yielding, the worker commits a safe history boundary, sends another nudge, and releases the lease. -7. Terminal delivery or intentional no-reply completion records the delivered - turn before acknowledging work. +7. Accepted provider delivery, intentional no-reply completion, or a durable + internal result records the terminal turn before acknowledging work. New messages that arrive during a run remain durable. `interrupt` work is eligible at the next safe boundary, while `defer` work follows normal ordering diff --git a/packages/junior/src/chat/task-execution/slack-work.ts b/packages/junior/src/chat/task-execution/slack-work.ts index 5429720e7..0f88c600f 100644 --- a/packages/junior/src/chat/task-execution/slack-work.ts +++ b/packages/junior/src/chat/task-execution/slack-work.ts @@ -866,7 +866,7 @@ export function createSlackConversationWorker( try { if (route === "mention") { await options.runtime.handleNewMention(thread, latestMessage, { - destination: context.destination, + destination, messageContext, drainSteeringMessages, ack, @@ -878,7 +878,7 @@ export function createSlackConversationWorker( thread, latestMessage, { - destination: context.destination, + destination, messageContext, drainSteeringMessages, ack, diff --git a/packages/junior/src/chat/task-execution/state.ts b/packages/junior/src/chat/task-execution/state.ts index aa8065268..ca861c915 100644 --- a/packages/junior/src/chat/task-execution/state.ts +++ b/packages/junior/src/chat/task-execution/state.ts @@ -108,7 +108,7 @@ export const inboundMessageSchema = z conversationId: z.string().refine((value) => value.trim().length > 0), createdAtMs: z.number().finite(), delivery: inboundMessageDeliverySchema, - destination: destinationSchema, + destination: destinationSchema.optional(), inboundMessageId: z.string().refine((value) => value.trim().length > 0), injectedAtMs: z.number().finite().optional(), input: agentInputSchema, @@ -479,10 +479,11 @@ function normalizeConversation( } if ( execution.pendingMessages.length > 0 && - (!destination || - execution.pendingMessages.some( - (message) => !sameDestination(message.destination, destination), - )) + execution.pendingMessages.some((message) => + message.destination + ? !destination || !sameDestination(message.destination, destination) + : Boolean(destination), + ) ) { return undefined; } @@ -1007,6 +1008,22 @@ function assertSameConversationDestination(args: { ); } +function assertSameOptionalConversationDestination(args: { + conversationId: string; + current: Destination | undefined; + next: Destination | undefined; +}): void { + if (!args.current && !args.next) { + return; + } + if (args.current && args.next && sameDestination(args.current, args.next)) { + return; + } + throw new Error( + `Conversation destination changed for ${args.conversationId}`, + ); +} + function conversationWorkState( conversation: Conversation, ): ConversationWorkState { @@ -1071,26 +1088,32 @@ export async function appendInboundMessage(args: { return await withConversationMutation( { conversationId: args.message.conversationId, state: args.state }, async (state, lock) => { + const existing = await readConversation( + state, + args.message.conversationId, + ); const current = - (await readConversation(state, args.message.conversationId)) ?? + existing ?? emptyConversation({ conversationId: args.message.conversationId, destination: args.message.destination, nowMs, source: args.message.source, }); - assertSameConversationDestination({ - conversationId: args.message.conversationId, - current: current.destination, - next: args.message.destination, - }); + if (existing) { + assertSameOptionalConversationDestination({ + conversationId: args.message.conversationId, + current: current.destination, + next: args.message.destination, + }); + } const existingPending = current.execution.pendingMessages.some( (message) => message.inboundMessageId === args.message.inboundMessageId, ); - const existing = current.execution.inboundMessageIds.includes( + const existingMessage = current.execution.inboundMessageIds.includes( args.message.inboundMessageId, ); - if (existing) { + if (existingMessage) { if (!existingPending) { return { status: "duplicate" }; } @@ -1159,7 +1182,7 @@ export async function appendInboundMessage(args: { /** Mark a conversation runnable when there is no new mailbox message. */ export async function requestConversationWork(args: { conversationId: string; - destination: Destination; + destination?: Destination; nowMs?: number; state?: StateAdapter; }): Promise { @@ -1167,7 +1190,7 @@ export async function requestConversationWork(args: { return await withConversationMutation(args, async (state, lock) => { const existing = await readConversation(state, args.conversationId); if (existing) { - assertSameConversationDestination({ + assertSameOptionalConversationDestination({ conversationId: args.conversationId, current: existing.destination, next: args.destination, @@ -1605,7 +1628,7 @@ export async function ackMessages(args: { /** Mark the leased conversation as needing another queue-delivered slice. */ export async function requestConversationContinuation(args: { conversationId: string; - destination: Destination; + destination?: Destination; leaseToken: string; nowMs?: number; state?: StateAdapter; @@ -1616,7 +1639,7 @@ export async function requestConversationContinuation(args: { if (!current || current.execution.lease?.token !== args.leaseToken) { return false; } - assertSameConversationDestination({ + assertSameOptionalConversationDestination({ conversationId: args.conversationId, current: current.destination, next: args.destination, diff --git a/packages/junior/src/chat/task-execution/store.ts b/packages/junior/src/chat/task-execution/store.ts index 05c256c61..64d4c71af 100644 --- a/packages/junior/src/chat/task-execution/store.ts +++ b/packages/junior/src/chat/task-execution/store.ts @@ -168,9 +168,6 @@ export async function ensureConversationWake(args: { if (!conversation || !hasRunnableConversationWork(conversation)) { return { status: "no_work" }; } - if (!conversation.destination) { - return { status: "no_work" }; - } if ( args.replaceExistingWake !== true && conversation.execution.lease && diff --git a/packages/junior/src/chat/task-execution/worker.ts b/packages/junior/src/chat/task-execution/worker.ts index 79248a890..9e2bc5bc8 100644 --- a/packages/junior/src/chat/task-execution/worker.ts +++ b/packages/junior/src/chat/task-execution/worker.ts @@ -38,14 +38,14 @@ export interface ConversationWorkerContext { attempt: InboxAttempt; checkIn(): Promise; conversationId: string; - destination: Destination; + destination?: Destination; shouldYield(): boolean; } export interface InboxAttempt { ack(): Promise; conversationId: string; - destination: Destination; + destination?: Destination; drain( handle: (messages: InboundMessage[]) => Promise, ): Promise; @@ -122,7 +122,7 @@ function nudgeIdempotencyKey( async function requestLostLeaseRecovery(args: { conversationId: string; - destination: Destination; + destination?: Destination; leaseToken: string; nowMs: number; options: ProcessConversationWorkOptions; @@ -283,13 +283,6 @@ async function processConversationWorkInContext( } return { status: "no_work" }; } - if (!initial.destination) { - throw new ConversationQueueMessageRejectedError( - "invalid_record", - `Conversation work is missing destination context for ${conversationId}`, - { conversationId }, - ); - } const destination = initial.destination; const lease = await startConversationWork({ @@ -683,6 +676,27 @@ async function processConversationWorkInContext( nowMs: errorNowMs, state: options.state, }); + } else if (failure?.status === "recorded") { + await releaseConversationWork({ + conversationId, + leaseToken: lease.leaseToken, + conversationStore: options.conversationStore, + nowMs: errorNowMs, + state: options.state, + }); + await ensureConversationWake({ + conversationId, + conversationStore: options.conversationStore, + idempotencyKey: nudgeIdempotencyKey( + "error", + conversationId, + errorNowMs, + ), + nowMs: errorNowMs, + queue: options.queue, + replaceExistingWake: true, + state: options.state, + }); } else { const resumeRequested = await requestConversationContinuation({ conversationId, diff --git a/packages/junior/src/chat/tools/index.ts b/packages/junior/src/chat/tools/index.ts index 5f06fb2bd..566a292ea 100644 --- a/packages/junior/src/chat/tools/index.ts +++ b/packages/junior/src/chat/tools/index.ts @@ -14,6 +14,7 @@ import { createSearchMcpToolsTool } from "@/chat/tools/skill/search-mcp-tools"; import { createReadFileTool } from "@/chat/tools/sandbox/read-file"; import { createViewImageTool } from "@/chat/tools/sandbox/view-image"; import { createReportProgressTool } from "@/chat/tools/runtime/report-progress"; +import { createSpawnAgentTool } from "@/chat/tools/runtime/spawn-agent"; import { createResourceEventTools } from "@/chat/tools/resource-events"; import { createSlackChannelListMessagesTool } from "@/chat/slack/tools/channel-list-messages"; import { createSlackConversationSearchTool } from "@/chat/slack/tools/conversation-search"; @@ -155,6 +156,10 @@ export function createTools( tools.handoff = createHandoffTool(context.handoff); } + if (context.spawnAgent) { + tools.spawnAgent = createSpawnAgentTool(context.spawnAgent); + } + if (context.mcpToolManager) { tools.searchMcpTools = createSearchMcpToolsTool(context.mcpToolManager); tools.callMcpTool = createCallMcpToolTool(context.mcpToolManager); diff --git a/packages/junior/src/chat/tools/runtime/spawn-agent.ts b/packages/junior/src/chat/tools/runtime/spawn-agent.ts new file mode 100644 index 000000000..a7d26bff0 --- /dev/null +++ b/packages/junior/src/chat/tools/runtime/spawn-agent.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; +import { AgentInvocationBusyError } from "@/chat/agent-invocations/errors"; +import { agentNameSchema } from "@/chat/agent-invocations/types"; +import { TURN_REASONING_LEVELS } from "@/chat/reasoning-level"; +import { juniorToolResultSchema } from "@/chat/tool-support/structured-result"; +import { zodTool } from "@/chat/tool-support/zod-tool"; +import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; +import type { ToolRuntimeContext } from "@/chat/tools/types"; + +export const SPAWN_AGENT_TOOL_NAME = "spawnAgent"; + +/** Create the asynchronous child-agent tool for one parent run. */ +export function createSpawnAgentTool( + spawnAgent: NonNullable, +) { + return zodTool({ + description: + "Start an asynchronous child agent on a delegated task. Give it a name to reuse the same child agent and its history on later calls; omit the name for a one-off agent. This call confirms durable scheduling, not task completion.", + inputSchema: z + .object({ + task: z.string().trim().min(1).describe("Task for the child agent"), + name: agentNameSchema + .nullable() + .optional() + .describe("Stable child-agent name, or omit for a one-off agent"), + reasoning_level: z + .enum(TURN_REASONING_LEVELS) + .nullable() + .optional() + .describe( + "Optional reasoning level; for a named agent this becomes its stable policy", + ), + }) + .strict(), + outputSchema: juniorToolResultSchema.extend({ + invocation_id: z.string().min(1), + }), + execute: async (input, options) => { + if (!options.toolCallId) { + throw new ToolInputError("spawnAgent requires an active tool call ID"); + } + let result; + try { + result = await spawnAgent( + { + task: input.task, + ...(input.name ? { name: input.name } : {}), + ...(input.reasoning_level + ? { reasoningLevel: input.reasoning_level } + : {}), + }, + { + ...(options.signal ? { signal: options.signal } : {}), + toolCallId: options.toolCallId, + }, + ); + } catch (error) { + if (error instanceof AgentInvocationBusyError) { + throw new ToolInputError( + `${error.message}. Wait for it to finish or use a different name.`, + { cause: error }, + ); + } + throw error; + } + return { + ok: true, + status: "success" as const, + invocation_id: result.invocationId, + }; + }, + }); +} diff --git a/packages/junior/src/chat/tools/types.ts b/packages/junior/src/chat/tools/types.ts index 44a6c45bf..7450dd2cd 100644 --- a/packages/junior/src/chat/tools/types.ts +++ b/packages/junior/src/chat/tools/types.ts @@ -19,6 +19,7 @@ import type { LocalActor, Actor, SlackActor } from "@/chat/actor"; import type { SlackActionToken } from "@/chat/slack/action-token"; import type { ModelProfile } from "@/chat/model-profile"; import type { GeneratedArtifactFileRef } from "@/chat/tools/sandbox/file-uploads"; +import type { SpawnAgent } from "@/chat/agent/request"; interface HandoffControl { /** Non-empty catalog of configured targets. */ @@ -76,6 +77,7 @@ export interface ToolHooks { interface BaseToolRuntimeContext { handoff?: HandoffControl; + spawnAgent?: SpawnAgent; /** * Opaque Junior conversation/session identity for this turn. * Interactive Slack turns use `slack:{channelId}:{threadTs}`. diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index c517c19d9..cb335415a 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -242,7 +242,35 @@ async function prepareLocalChatRun( const { createLocalOAuthState } = await import("@/chat/local/oauth-relay"); const { createLocalSandboxEgressSignalTransport } = await import("@/chat/local/sandbox-egress-signals"); - const agentRunner = createAgentRunner(executeAgentRun); + const { bindSpawnAgent } = await import("@/chat/agent-invocations/spawn"); + const { createAgentInvocationWorker, routeAgentInvocationWork } = + await import("@/chat/agent-invocations/work"); + const { createLocalConversationWork } = + await import("@/chat/local/conversation-work"); + const { processConversationWork } = + await import("@/chat/task-execution/worker"); + let agentRunner: ReturnType | undefined; + const localConversationWork = createLocalConversationWork(async (message) => { + if (!agentRunner) { + throw new Error("Local agent runner is not ready"); + } + const run = routeAgentInvocationWork({ + fallbackWorker: async () => { + throw new Error("Local child queue received non-invocation work"); + }, + invocationWorker: createAgentInvocationWorker({ + agentRunner, + }), + }); + await processConversationWork(message, { + queue: localConversationWork.queue, + run, + }); + }); + agentRunner = createAgentRunner(executeAgentRun, { + bindSpawnAgent: (request) => + bindSpawnAgent(request, { queue: localConversationWork.queue }), + }); const oauthCallback = await startLocalOAuthCallbackServer(agentRunner); const deps: LocalAgentTurnDeps = { agentRunner, @@ -271,7 +299,13 @@ async function prepareLocalChatRun( }, }; return { - close: oauthCallback.close, + close: async () => { + try { + await localConversationWork.drain(); + } finally { + await oauthCallback.close(); + } + }, conversationId: newRunConversationId(), runLocalAgentTurn, deps, diff --git a/packages/junior/src/db/schema.ts b/packages/junior/src/db/schema.ts index caca3b57d..7db71d034 100644 --- a/packages/junior/src/db/schema.ts +++ b/packages/junior/src/db/schema.ts @@ -2,6 +2,10 @@ import { juniorConversationAnnotations } from "./schema/conversation-annotations import { juniorApiTokens } from "./schema/api-tokens"; import { juniorConversationEvents } from "./schema/conversation-events"; import { juniorConversations } from "./schema/conversations"; +import { + juniorAgentBindings, + juniorAgentInvocations, +} from "./schema/agent-invocations"; import { juniorDestinations } from "./schema/destinations"; import { juniorIdentities } from "./schema/identities"; import { juniorStats } from "./schema/stats"; @@ -10,6 +14,8 @@ import { juniorUsers } from "./schema/users"; export { juniorConversationAnnotations, juniorApiTokens, + juniorAgentBindings, + juniorAgentInvocations, juniorConversationEvents, juniorConversations, juniorDestinations, @@ -21,6 +27,8 @@ export { export const juniorSqlSchema = { juniorConversationAnnotations, juniorApiTokens, + juniorAgentBindings, + juniorAgentInvocations, juniorConversationEvents, juniorConversations, juniorDestinations, diff --git a/packages/junior/src/db/schema/agent-invocations.ts b/packages/junior/src/db/schema/agent-invocations.ts new file mode 100644 index 000000000..40e6ccb08 --- /dev/null +++ b/packages/junior/src/db/schema/agent-invocations.ts @@ -0,0 +1,93 @@ +import { + index, + jsonb, + pgTable, + primaryKey, + text, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import type { Actor, Destination, Source } from "@sentry/junior-plugin-api"; +import type { ConversationPrivacy } from "@/chat/conversation-privacy"; +import type { CredentialContext } from "@/chat/credentials/context"; +import type { TurnReasoningLevel } from "@/chat/reasoning-level"; +import { juniorConversations } from "./conversations"; +import { timestamptz } from "./timestamps"; + +export const AGENT_INVOCATION_STATUSES = [ + "pending", + "running", + "awaiting_resume", + "blocked", + "completed", + "failed", +] as const; + +export const AGENT_INVOCATION_MAILBOX_STATUSES = [ + "pending", + "appended", +] as const; + +export const juniorAgentBindings = pgTable( + "junior_agent_bindings", + { + parentConversationId: text("parent_conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + name: text("name").notNull(), + childConversationId: text("child_conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + reasoningLevel: text("reasoning_level").$type(), + }, + (table) => [ + primaryKey({ + columns: [table.parentConversationId, table.name], + }), + uniqueIndex("junior_agent_bindings_child_idx").on( + table.childConversationId, + ), + ], +); + +export const juniorAgentInvocations = pgTable( + "junior_agent_invocations", + { + invocationId: text("invocation_id").primaryKey(), + parentConversationId: text("parent_conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + childConversationId: text("child_conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + agentName: text("agent_name"), + input: text("input").notNull(), + actor: jsonb("actor_json").$type().notNull(), + credentialContext: jsonb( + "credential_context_json", + ).$type(), + source: jsonb("source_json").$type().notNull(), + destination: jsonb("destination_json").$type().notNull(), + destinationVisibility: text( + "destination_visibility", + ).$type(), + reasoningLevel: text("reasoning_level").$type(), + status: text("status") + .$type<(typeof AGENT_INVOCATION_STATUSES)[number]>() + .notNull(), + mailboxStatus: text("mailbox_status") + .$type<(typeof AGENT_INVOCATION_MAILBOX_STATUSES)[number]>() + .notNull(), + result: text("result"), + errorMessage: text("error_message"), + createdAt: timestamptz("created_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + terminalAt: timestamptz("terminal_at"), + }, + (table) => [ + index("junior_agent_invocations_child_idx").on(table.childConversationId), + index("junior_agent_invocations_mailbox_idx").on( + table.mailboxStatus, + table.createdAt, + ), + ], +); diff --git a/packages/junior/tests/component/cli/local-chat-composition.test.ts b/packages/junior/tests/component/cli/local-chat-composition.test.ts index dbaf4adc5..a166b60d6 100644 --- a/packages/junior/tests/component/cli/local-chat-composition.test.ts +++ b/packages/junior/tests/component/cli/local-chat-composition.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentRunRequest, SpawnAgentResult } from "@/chat/agent/request"; import type { AgentRunResult } from "@/chat/services/turn-result"; import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; import { deliverAssistantMessagesForTest } from "../../fixtures/agent-runner"; @@ -140,4 +141,222 @@ export const plugins = { rmSync(tempDir, { force: true, recursive: true }); } }, 30_000); + + it("finishes spawned child work in process before prompt mode exits", async () => { + delete process.env.JUNIOR_STATE_ADAPTER; + const requests: AgentRunRequest[] = []; + executeAgentRunMock.mockImplementation(async (request) => { + requests.push(request); + if (request.policy?.agentSpawning === "disabled") { + await request.durability.onInputCommitted?.(); + return completedAgentRun(successReply("child finished")); + } + const spawnAgent = request.durability.spawnAgent; + if (!spawnAgent) { + throw new Error("parent run requires spawnAgent"); + } + const spawned = await spawnAgent( + { + name: "local-test-child", + reasoningLevel: "medium", + task: "Finish the child task.", + }, + { toolCallId: "spawn-1" }, + ); + expect(spawned.invocationId).toBeTruthy(); + await deliverAssistantMessagesForTest(request, [ + { text: "child scheduled" }, + ]); + return completedAgentRun(successReply("child scheduled")); + }); + const output: string[] = []; + + const { runChat } = await import("@/cli/chat"); + await expect( + runChat( + ["-p", "delegate this"], + { + error: vi.fn(), + input: process.stdin, + output: process.stdout, + write: (text) => { + output.push(text); + }, + }, + { pluginSet: null }, + ), + ).resolves.toBe(0); + + expect(executeAgentRunMock).toHaveBeenCalledTimes(2); + expect(requests[0]?.durability?.spawnAgent).toBeDefined(); + expect(requests[1]).toMatchObject({ + policy: { agentSpawning: "disabled" }, + }); + expect(requests[1]?.durability?.spawnAgent).toBeUndefined(); + expect(output).toEqual(["child scheduled\n"]); + }, 30_000); + + it("runs successive work through the same completed named child", async () => { + delete process.env.JUNIOR_STATE_ADAPTER; + const childTasks: string[] = []; + const spawns: SpawnAgentResult[] = []; + const { getAgentInvocation } = + await import("@/chat/agent-invocations/store"); + executeAgentRunMock.mockImplementation(async (request) => { + if (request.policy?.agentSpawning === "disabled") { + childTasks.push(request.input.messageText); + await request.durability.onInputCommitted?.(); + return completedAgentRun( + successReply(`completed:${request.input.messageText}`), + ); + } + const spawnAgent = request.durability.spawnAgent; + if (!spawnAgent) { + throw new Error("parent run requires named child dependencies"); + } + const first = await spawnAgent( + { + name: "reused-child", + task: "first named task", + }, + { toolCallId: "named-1" }, + ); + spawns.push(first); + await vi.waitFor( + async () => { + await expect( + getAgentInvocation(first.invocationId), + ).resolves.toMatchObject({ + status: "completed", + }); + }, + { timeout: 5_000 }, + ); + const second = await spawnAgent( + { + name: "reused-child", + task: "second named task", + }, + { toolCallId: "named-2" }, + ); + spawns.push(second); + await deliverAssistantMessagesForTest(request, [ + { text: "named work scheduled" }, + ]); + return completedAgentRun(successReply("named work scheduled")); + }); + + const { runChat } = await import("@/cli/chat"); + await expect( + runChat( + ["-p", "reuse one named child"], + { + error: vi.fn(), + input: process.stdin, + output: process.stdout, + write: vi.fn(), + }, + { pluginSet: null }, + ), + ).resolves.toBe(0); + + expect(spawns).toHaveLength(2); + expect(spawns[1]?.invocationId).not.toBe(spawns[0]?.invocationId); + expect(childTasks).toEqual(["first named task", "second named task"]); + const secondSpawn = spawns[1]; + if (!secondSpawn) { + throw new Error("Expected second named spawn"); + } + const [firstInvocation, secondInvocation] = await Promise.all( + spawns.map(async (spawn) => await getAgentInvocation(spawn.invocationId)), + ); + expect(secondInvocation?.childConversationId).toBe( + firstInvocation?.childConversationId, + ); + expect(secondInvocation).toMatchObject({ + result: "completed:second named task", + status: "completed", + }); + }, 30_000); + + it("runs independent spawned children concurrently and drains both", async () => { + delete process.env.JUNIOR_STATE_ADAPTER; + const spawns: SpawnAgentResult[] = []; + let activeChildren = 0; + let maxActiveChildren = 0; + let releaseBoth: (() => void) | undefined; + const bothStarted = new Promise((resolve) => { + releaseBoth = resolve; + }); + executeAgentRunMock.mockImplementation(async (request) => { + if (request.policy?.agentSpawning === "disabled") { + activeChildren += 1; + maxActiveChildren = Math.max(maxActiveChildren, activeChildren); + if (activeChildren === 2) { + releaseBoth?.(); + } + await bothStarted; + await request.durability.onInputCommitted?.(); + activeChildren -= 1; + return completedAgentRun( + successReply(`completed:${request.input.messageText}`), + ); + } + const spawnAgent = request.durability.spawnAgent; + if (!spawnAgent) { + throw new Error("parent run requires spawnAgent"); + } + spawns.push( + ...(await Promise.all([ + spawnAgent( + { name: "parallel-a", task: "parallel task a" }, + { toolCallId: "parallel-1" }, + ), + spawnAgent( + { name: "parallel-b", task: "parallel task b" }, + { toolCallId: "parallel-2" }, + ), + ])), + ); + await deliverAssistantMessagesForTest(request, [ + { text: "parallel work scheduled" }, + ]); + return completedAgentRun(successReply("parallel work scheduled")); + }); + + const { runChat } = await import("@/cli/chat"); + const { getAgentInvocation } = + await import("@/chat/agent-invocations/store"); + await expect( + runChat( + ["-p", "run two children in parallel"], + { + error: vi.fn(), + input: process.stdin, + output: process.stdout, + write: vi.fn(), + }, + { pluginSet: null }, + ), + ).resolves.toBe(0); + + expect(maxActiveChildren).toBe(2); + expect(spawns).toHaveLength(2); + const invocations = await Promise.all( + spawns.map(async (spawn) => await getAgentInvocation(spawn.invocationId)), + ); + expect(invocations[0]?.childConversationId).not.toBe( + invocations[1]?.childConversationId, + ); + expect(invocations).toEqual([ + expect.objectContaining({ + result: "completed:parallel task a", + status: "completed", + }), + expect.objectContaining({ + result: "completed:parallel task b", + status: "completed", + }), + ]); + }, 30_000); }); diff --git a/packages/junior/tests/component/conversations/retention.test.ts b/packages/junior/tests/component/conversations/retention.test.ts index 9802dbeb2..4bebb620b 100644 --- a/packages/junior/tests/component/conversations/retention.test.ts +++ b/packages/junior/tests/component/conversations/retention.test.ts @@ -14,6 +14,8 @@ import { juniorConversationEvents, juniorConversations, juniorDestinations, + juniorAgentBindings, + juniorAgentInvocations, } from "@/db/schema"; import type { JuniorDestinationVisibility } from "@/db/schema/destinations"; import type { JuniorSqlDatabase } from "@/db/db"; @@ -248,6 +250,29 @@ describe("retention purge job", () => { parentConversationId: "child", lastActivityAtMs: BASE_MS, }); + await fixture.sql + .db() + .insert(juniorAgentInvocations) + .values({ + actor: { name: "parent-agent", platform: "system" }, + childConversationId: "child", + createdAt: new Date(BASE_MS), + destination: { conversationId: "root", platform: "local" }, + destinationVisibility: "private", + input: "private delegated input", + invocationId: "agent-invocation:retained", + mailboxStatus: "appended", + parentConversationId: "root", + result: "private delegated result", + source: { + conversationId: "root", + platform: "local", + type: "priv", + }, + status: "completed", + terminalAt: new Date(BASE_MS), + updatedAt: new Date(BASE_MS), + }); // The child is never a purge candidate on its own: it rides the root. const result = await runRetentionPurge(fixture.sql, { @@ -264,6 +289,9 @@ describe("retention purge job", () => { expect(await eventCount(fixture.sql, "root")).toBe(0); expect(await eventCount(fixture.sql, "child")).toBe(0); expect(await eventCount(fixture.sql, "grandchild")).toBe(0); + await expect( + fixture.sql.db().select().from(juniorAgentInvocations), + ).resolves.toEqual([]); }); it("uses the freshest activity in the tree for selection and destructive recheck", async () => { @@ -383,6 +411,51 @@ describe("retention purge job", () => { expect(await eventCount(fixture.sql, "remaining-child")).toBe(0); }); + it("selects and purges a tree whose remaining content is only an agent binding", async () => { + const dest = await seedDestination(fixture.sql, "private"); + await seedConversation(fixture.sql, { + conversationId: "binding-root", + destinationId: dest, + lastActivityAtMs: BASE_MS, + title: null, + channelName: null, + withContent: false, + }); + await fixture.sql + .db() + .update(juniorConversations) + .set({ actor: null }) + .where(eq(juniorConversations.conversationId, "binding-root")); + await seedConversation(fixture.sql, { + conversationId: "binding-child", + parentConversationId: "binding-root", + lastActivityAtMs: BASE_MS, + title: null, + channelName: null, + withContent: false, + }); + await fixture.sql + .db() + .update(juniorConversations) + .set({ actor: null }) + .where(eq(juniorConversations.conversationId, "binding-child")); + await fixture.sql.db().insert(juniorAgentBindings).values({ + childConversationId: "binding-child", + name: "retained-name", + parentConversationId: "binding-root", + reasoningLevel: "high", + }); + + const result = await runRetentionPurge(fixture.sql, { + nowMs: BASE_MS + 30 * DAY_MS, + }); + + expect(result.purged).toBe(1); + await expect( + fixture.sql.db().select().from(juniorAgentBindings), + ).resolves.toEqual([]); + }); + it("purges up to the batch limit and leaves the remainder for the next run", async () => { const dest = await seedDestination(fixture.sql, "private"); await seedConversation(fixture.sql, { diff --git a/packages/junior/tests/component/services/turn-session-record.test.ts b/packages/junior/tests/component/services/turn-session-record.test.ts index cf1b32772..6e69b1453 100644 --- a/packages/junior/tests/component/services/turn-session-record.test.ts +++ b/packages/junior/tests/component/services/turn-session-record.test.ts @@ -57,6 +57,7 @@ function assistantMessage(text: string, timestamp: number): PiMessage { function failingConversationStore(): ConversationStore { return { + createChild: vi.fn(), get: vi.fn(), getDestinationVisibility: vi.fn(async () => undefined), recordActivity: vi.fn(async () => { diff --git a/packages/junior/tests/component/task-execution/conversation-work.test.ts b/packages/junior/tests/component/task-execution/conversation-work.test.ts index 6f3538421..c9a7d54c8 100644 --- a/packages/junior/tests/component/task-execution/conversation-work.test.ts +++ b/packages/junior/tests/component/task-execution/conversation-work.test.ts @@ -66,6 +66,7 @@ const CONVERSATION_WORK_STATE_KEY = `junior:conversation:${CONVERSATION_ID}`; function failingMetadataStore(): ConversationStore { return { + createChild: vi.fn(), get: vi.fn(async () => undefined), getDestinationVisibility: vi.fn(async () => undefined), recordActivity: vi.fn(), @@ -78,6 +79,7 @@ function failingMetadataStore(): ConversationStore { function metadataEventsStore(events: string[]): ConversationStore { return { + createChild: vi.fn(), get: vi.fn(async () => undefined), getDestinationVisibility: vi.fn(async () => undefined), recordActivity: vi.fn(), @@ -690,6 +692,24 @@ describe("conversation work execution", () => { ).rejects.toThrow(`Conversation record is invalid for ${CONVERSATION_ID}`); }); + it("rejects appending destinationless work to a provider conversation", async () => { + await appendInboundMessage({ + message: inboundMessage("m1"), + nowMs: 1_000, + }); + + await expect( + appendInboundMessage({ + message: { + ...inboundMessage("m2"), + destination: undefined, + source: "internal", + }, + nowMs: 2_000, + }), + ).rejects.toThrow("Conversation destination changed"); + }); + it("defers duplicate queue nudges while a conversation lease is active", async () => { const queue = createConversationWorkQueueTestAdapter(); await appendInboundMessage({ message: inboundMessage("m1"), nowMs: 1_000 }); @@ -849,6 +869,14 @@ describe("conversation work execution", () => { nowMs: 3_000, }), ).rejects.toThrow("Conversation destination changed"); + await expect( + requestConversationContinuation({ + conversationId: CONVERSATION_ID, + destination: undefined, + leaseToken: lease.leaseToken, + nowMs: 3_000, + }), + ).rejects.toThrow("Conversation destination changed"); await expect( getConversationWorkState({ conversationId: CONVERSATION_ID }), ).resolves.toMatchObject({ @@ -949,7 +977,7 @@ describe("conversation work execution", () => { await scheduleAgentContinue( { conversationId: context.conversationId, - destination: context.destination, + destination: context.destination ?? SLACK_DESTINATION, expectedVersion: 2, sessionId: "turn-1", }, @@ -989,7 +1017,7 @@ describe("conversation work execution", () => { await scheduleAgentContinue( { conversationId: context.conversationId, - destination: context.destination, + destination: context.destination ?? SLACK_DESTINATION, expectedVersion: 2, sessionId: "turn-1", }, @@ -1038,7 +1066,7 @@ describe("conversation work execution", () => { await scheduleAgentContinue( { conversationId: context.conversationId, - destination: context.destination, + destination: context.destination ?? SLACK_DESTINATION, expectedVersion: 2, sessionId: "turn-1", }, @@ -1153,7 +1181,7 @@ describe("conversation work execution", () => { await scheduleAgentContinue( { conversationId: context.conversationId, - destination: context.destination, + destination: context.destination ?? SLACK_DESTINATION, expectedVersion: 2, sessionId: "turn-1", }, @@ -1985,7 +2013,7 @@ describe("conversation work execution", () => { await scheduleAgentContinue( { conversationId: context.conversationId, - destination: context.destination, + destination: context.destination ?? SLACK_DESTINATION, expectedVersion: 2, sessionId: "turn-1", }, diff --git a/packages/junior/tests/integration/agent-invocation-concurrency.test.ts b/packages/junior/tests/integration/agent-invocation-concurrency.test.ts new file mode 100644 index 000000000..22dde0624 --- /dev/null +++ b/packages/junior/tests/integration/agent-invocation-concurrency.test.ts @@ -0,0 +1,424 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createLocalSource } from "@sentry/junior-plugin-api"; +import type { AgentRunRequest } from "@/chat/agent/request"; +import type { PiMessage } from "@/chat/pi/messages"; +import { getAgentInvocation } from "@/chat/agent-invocations/store"; +import { + createAgentInvocationWorker, + routeAgentInvocationWork, + createAndEnqueueAgentInvocation, +} from "@/chat/agent-invocations/work"; +import type { CreateAgentInvocationInput } from "@/chat/agent-invocations/types"; +import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; +import { migrateSchema } from "@/chat/conversations/sql/migrations"; +import { createSqlStore } from "@/chat/conversations/sql/store"; +import { persistRunningSessionRecord } from "@/chat/services/turn-session-record"; +import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; +import { processConversationQueueMessage } from "@/chat/task-execution/vercel-callback"; +import { CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS } from "@/chat/task-execution/store"; +import { createConversationWorkQueueTestAdapter } from "../fixtures/conversation-work"; +import { createConfiguredJuniorSqlFixture } from "../fixtures/sql"; + +const parentConversationId = "local:test:agent-invocation-concurrency"; +const destination = { + conversationId: parentConversationId, + platform: "local", +} as const; +const baseInput = { + actor: { name: "parent-agent", platform: "system" } as const, + destination, + destinationVisibility: "private" as const, + input: "Do the delegated work.", + parentConversationId, + source: createLocalSource(parentConversationId), +}; + +async function successfulRun( + request: AgentRunRequest, + result: string, +): Promise> { + const timestamp = Date.now(); + const runningMessages = [ + ...(request.input.piMessages ?? []), + { + role: "user", + content: [{ type: "text", text: request.input.messageText }], + timestamp, + }, + ] as PiMessage[]; + const persisted = await persistRunningSessionRecord({ + conversationId: request.conversationId, + destination: request.routing.destination, + messages: runningMessages, + modelId: "integration-agent", + actor: request.routing.actor, + sessionId: request.turnId, + sliceId: 1, + source: request.routing.source, + surface: "internal", + }); + if (!persisted) { + throw new Error("Expected running child session to persist"); + } + const piMessages = [ + ...runningMessages, + { + role: "assistant", + content: [{ type: "text", text: result }], + timestamp: timestamp + 1, + }, + ] as PiMessage[]; + return completedAgentRun({ + diagnostics: { + assistantMessageCount: 1, + modelId: "integration-agent", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + piMessages, + text: result, + }); +} + +async function createHarness( + run: ( + request: AgentRunRequest, + ) => Promise>, +) { + const fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const conversationStore = createSqlStore(fixture.sql); + await conversationStore.recordActivity({ + conversationId: parentConversationId, + destination, + nowMs: 1_000, + source: "local", + }); + const state = getStateAdapter(); + await state.connect(); + const queue = createConversationWorkQueueTestAdapter(); + const route = routeAgentInvocationWork({ + fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), + invocationWorker: createAgentInvocationWorker({ + agentRunner: { run }, + }), + }); + + return { + async close() { + await fixture.close(); + }, + async drainQueuedWork() { + while (queue.hasQueuedMessages()) { + const batch = queue.queuedMessages(); + await Promise.all( + batch.map(async () => { + await processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }); + }), + ); + } + }, + processQueuedBatch: async () => { + const batch = queue.queuedMessages(); + await Promise.all( + batch.map(async () => { + await processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }); + }), + ); + }, + queue, + spawn: async ( + overrides: Partial & { + idempotencyKey: string; + }, + ) => + await createAndEnqueueAgentInvocation( + { ...baseInput, ...overrides }, + { conversationStore, queue, state }, + ), + }; +} + +describe("agent invocation identity and concurrency", () => { + afterEach(async () => { + await disconnectStateAdapter(); + vi.restoreAllMocks(); + }); + + it("gives each unnamed invocation a fresh child without inherited history", async () => { + const requests: AgentRunRequest[] = []; + const harness = await createHarness(async (request) => { + requests.push(request); + await request.durability?.onInputCommitted?.(); + return await successfulRun( + request, + `result:${request.input.messageText}`, + ); + }); + try { + const first = await harness.spawn({ + idempotencyKey: "fresh-1", + input: "first unnamed task", + }); + await harness.drainQueuedWork(); + const second = await harness.spawn({ + idempotencyKey: "fresh-2", + input: "second unnamed task", + }); + await harness.drainQueuedWork(); + + expect(second.childConversationId).not.toBe(first.childConversationId); + expect(requests).toHaveLength(2); + expect(requests.map((request) => request.input.piMessages)).toEqual([ + [], + [], + ]); + await expect( + getAgentInvocation(first.invocationId), + ).resolves.toMatchObject({ + result: "result:first unnamed task", + status: "completed", + }); + await expect( + getAgentInvocation(second.invocationId), + ).resolves.toMatchObject({ + result: "result:second unnamed task", + status: "completed", + }); + } finally { + await harness.close(); + } + }); + + it("reuses one named child and supplies its completed history", async () => { + const requests: AgentRunRequest[] = []; + const harness = await createHarness(async (request) => { + requests.push(request); + await request.durability?.onInputCommitted?.(); + return await successfulRun( + request, + `result:${request.input.messageText}`, + ); + }); + try { + const first = await harness.spawn({ + agentName: "researcher", + idempotencyKey: "named-1", + input: "first named task", + reasoningLevel: "high", + }); + await harness.drainQueuedWork(); + const second = await harness.spawn({ + agentName: "researcher", + idempotencyKey: "named-2", + input: "second named task", + }); + await harness.drainQueuedWork(); + + expect(second.childConversationId).toBe(first.childConversationId); + expect(requests).toHaveLength(2); + expect(requests[0]?.input.piMessages).toEqual([]); + expect(requests[0]?.policy?.reasoningLevel).toBe("high"); + expect(requests[1]?.policy?.reasoningLevel).toBe("high"); + expect(requests[1]?.input.piMessages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ role: "user" }), + expect.objectContaining({ role: "assistant" }), + ]), + ); + expect(JSON.stringify(requests[1]?.input.piMessages)).toContain( + "result:first named task", + ); + await expect( + getAgentInvocation(second.invocationId), + ).resolves.toMatchObject({ + reasoningLevel: "high", + result: "result:second named task", + status: "completed", + }); + } finally { + await harness.close(); + } + }); + + it("runs different named children in parallel without sharing history", async () => { + const started = new Set(); + const histories = new Map(); + let active = 0; + let maxActive = 0; + let release: (() => void) | undefined; + const blocked = new Promise((resolve) => { + release = resolve; + }); + let bothStarted: (() => void) | undefined; + const startedPromise = new Promise((resolve) => { + bothStarted = resolve; + }); + const harness = await createHarness(async (request) => { + started.add(request.input.messageText); + histories.set(request.input.messageText, request.input.piMessages); + active += 1; + maxActive = Math.max(maxActive, active); + if (started.size === 2) { + bothStarted?.(); + } + await blocked; + active -= 1; + await request.durability?.onInputCommitted?.(); + return await successfulRun( + request, + `result:${request.input.messageText}`, + ); + }); + try { + const [first, second] = await Promise.all([ + harness.spawn({ + agentName: "researcher", + idempotencyKey: "parallel-1", + input: "parallel first", + }), + harness.spawn({ + agentName: "reviewer", + idempotencyKey: "parallel-2", + input: "parallel second", + }), + ]); + const processing = harness.processQueuedBatch(); + await startedPromise; + + expect(maxActive).toBe(2); + expect(Object.fromEntries(histories)).toEqual({ + "parallel first": [], + "parallel second": [], + }); + expect(first.childConversationId).not.toBe(second.childConversationId); + release?.(); + await processing; + await expect( + getAgentInvocation(first.invocationId), + ).resolves.toMatchObject({ + result: "result:parallel first", + status: "completed", + }); + await expect( + getAgentInvocation(second.invocationId), + ).resolves.toMatchObject({ + result: "result:parallel second", + status: "completed", + }); + } finally { + release?.(); + await harness.close(); + } + }); + + it("coalesces concurrent replay and rejects overlapping work for one name", async () => { + const run = vi.fn(async (request: AgentRunRequest) => { + await request.durability?.onInputCommitted?.(); + return await successfulRun(request, "completed once"); + }); + const harness = await createHarness(run); + try { + const replayInput = { + agentName: "replayed", + idempotencyKey: "same-call", + input: "same task", + }; + const [first, replay] = await Promise.all([ + harness.spawn(replayInput), + harness.spawn(replayInput), + ]); + + expect(replay.invocationId).toBe(first.invocationId); + expect(harness.queue.sentRecords()).toHaveLength(1); + + const contention = await Promise.allSettled([ + harness.spawn({ + agentName: "busy", + idempotencyKey: "busy-1", + input: "first busy task", + }), + harness.spawn({ + agentName: "busy", + idempotencyKey: "busy-2", + input: "second busy task", + }), + ]); + expect( + contention.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + const rejected = contention.find( + (result) => result.status === "rejected", + ); + expect(rejected).toMatchObject({ + reason: expect.objectContaining({ + name: "AgentInvocationBusyError", + }), + status: "rejected", + }); + + await harness.drainQueuedWork(); + expect(run).toHaveBeenCalledTimes(2); + } finally { + await harness.close(); + } + }); + + it("keeps a successful parallel child independent from a failing sibling", async () => { + const attempts = new Map(); + const harness = await createHarness(async (request) => { + const task = request.input.messageText; + attempts.set(task, (attempts.get(task) ?? 0) + 1); + if (task === "failing sibling") { + throw new Error("sibling failed"); + } + await request.durability?.onInputCommitted?.(); + return await successfulRun(request, "successful sibling result"); + }); + try { + const [successful, failing] = await Promise.all([ + harness.spawn({ + idempotencyKey: "sibling-success", + input: "successful sibling", + }), + harness.spawn({ + idempotencyKey: "sibling-failure", + input: "failing sibling", + }), + ]); + await harness.drainQueuedWork(); + + expect(attempts.get("successful sibling")).toBe(1); + expect(attempts.get("failing sibling")).toBe( + CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS, + ); + await expect( + getAgentInvocation(successful.invocationId), + ).resolves.toMatchObject({ + result: "successful sibling result", + status: "completed", + }); + await expect( + getAgentInvocation(failing.invocationId), + ).resolves.toMatchObject({ + errorMessage: "sibling failed", + status: "failed", + }); + } finally { + await harness.close(); + } + }); +}); diff --git a/packages/junior/tests/integration/agent-invocation-work.test.ts b/packages/junior/tests/integration/agent-invocation-work.test.ts new file mode 100644 index 000000000..d6a5efbe7 --- /dev/null +++ b/packages/junior/tests/integration/agent-invocation-work.test.ts @@ -0,0 +1,728 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createLocalSource } from "@sentry/junior-plugin-api"; +import type { PiMessage } from "@/chat/pi/messages"; +import { + completeAgentInvocation, + createAgentInvocation, + getAgentInvocation, + getAgentInvocationTurnId, +} from "@/chat/agent-invocations/store"; +import { + buildAgentInvocationInboundMessage, + createAgentInvocationWorker, + routeAgentInvocationWork, + createAndEnqueueAgentInvocation, +} from "@/chat/agent-invocations/work"; +import { bindSpawnAgent } from "@/chat/agent-invocations/spawn"; +import { createSpawnAgentTool } from "@/chat/tools/runtime/spawn-agent"; +import type { AgentRunRequest } from "@/chat/agent/request"; +import { migrateSchema } from "@/chat/conversations/sql/migrations"; +import { createSqlStore } from "@/chat/conversations/sql/store"; +import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; +import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; +import { processConversationQueueMessage } from "@/chat/task-execution/vercel-callback"; +import { recoverPendingAgentInvocationMailboxAppends } from "@/chat/agent-dispatch/heartbeat"; +import { CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS } from "@/chat/task-execution/store"; +import type { ConversationWorkerContext } from "@/chat/task-execution/worker"; +import { + getAgentTurnSessionRecord, + upsertAgentTurnSessionRecord, +} from "@/chat/state/turn-session"; +import { createConversationWorkQueueTestAdapter } from "../fixtures/conversation-work"; +import { createConfiguredJuniorSqlFixture } from "../fixtures/sql"; + +const parentConversationId = "local:test:parent-agent"; +const destination = { + conversationId: parentConversationId, + platform: "local", +} as const; +const invocationInput = { + actor: { name: "parent-agent", platform: "system" } as const, + destination, + destinationVisibility: "private" as const, + input: "Summarize the durable task.", + parentConversationId, + reasoningLevel: "medium" as const, + source: createLocalSource(parentConversationId), +}; + +async function prepareParentConversation() { + const fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const conversationStore = createSqlStore(fixture.sql); + await conversationStore.recordActivity({ + conversationId: parentConversationId, + destination, + nowMs: 1_000, + source: "local", + }); + return { conversationStore, fixture }; +} + +describe("agent invocation conversation work", () => { + afterEach(async () => { + await disconnectStateAdapter(); + vi.restoreAllMocks(); + }); + + it("keeps named bindings stable and idempotent invocation input immutable", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + try { + const first = await createAgentInvocation( + { + ...invocationInput, + agentName: "researcher", + idempotencyKey: "named-1", + }, + 2_000, + ); + const replay = await createAgentInvocation( + { + ...invocationInput, + agentName: "researcher", + idempotencyKey: "named-1", + }, + 3_000, + ); + await completeAgentInvocation({ + invocationId: first.invocationId, + result: "Finished.", + status: "completed", + }); + const next = await createAgentInvocation( + { + ...invocationInput, + agentName: "researcher", + idempotencyKey: "named-2", + }, + 4_000, + ); + const unnamed = await createAgentInvocation( + { + ...invocationInput, + idempotencyKey: "unnamed-1", + }, + 5_000, + ); + const unnamedReplay = await createAgentInvocation( + { + ...invocationInput, + idempotencyKey: "unnamed-1", + }, + 6_000, + ); + const otherUnnamed = await createAgentInvocation( + { + ...invocationInput, + idempotencyKey: "unnamed-2", + }, + 7_000, + ); + + expect(replay).toEqual(first); + expect(next.invocationId).not.toBe(first.invocationId); + expect(next.childConversationId).toBe(first.childConversationId); + expect(unnamedReplay.childConversationId).toBe( + unnamed.childConversationId, + ); + expect(otherUnnamed.childConversationId).not.toBe( + unnamed.childConversationId, + ); + const child = await conversationStore.get({ + conversationId: first.childConversationId, + }); + expect(child).toMatchObject({ + lineage: { parentConversationId }, + source: "internal", + }); + expect(child).not.toHaveProperty("destination"); + await expect( + createAgentInvocation({ + ...invocationInput, + idempotencyKey: "named-1", + input: "Different input must not reuse the key.", + agentName: "researcher", + }), + ).rejects.toThrow("idempotency key was reused with different input"); + await expect( + createAgentInvocation({ + ...invocationInput, + agentName: "researcher", + idempotencyKey: "named-policy-change", + reasoningLevel: "high", + }), + ).rejects.toThrow("Named agent binding policy changed for researcher"); + await expect( + createAgentInvocation({ + ...invocationInput, + idempotencyKey: "recursive", + parentConversationId: first.childConversationId, + }), + ).rejects.toThrow("Recursive agent delegation is not enabled"); + } finally { + await fixture.close(); + } + }); + + it("derives spawn authority from the parent run and replays one tool call", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + try { + const request = { + conversationId: parentConversationId, + turnId: "parent-turn", + input: { + messageText: "Delegate the investigation.", + }, + routing: { + actor: { platform: "local", userId: "local-user" }, + credentialContext: { + actor: { type: "user", userId: "local-user" }, + }, + destination, + destinationVisibility: "private", + source: createLocalSource(parentConversationId), + }, + } satisfies AgentRunRequest; + const spawnAgent = bindSpawnAgent(request, { + conversationStore, + queue, + }); + expect(spawnAgent).toBeDefined(); + const tool = createSpawnAgentTool(spawnAgent!); + const input = tool.prepareArguments!({ + task: "Inspect the failing checks.", + name: "reviewer", + reasoning_level: "high", + }); + + const first = await tool.execute!(input, { toolCallId: "call-1" }); + const replay = await tool.execute!(input, { toolCallId: "call-1" }); + + expect(first.invocation_id).toBeTruthy(); + expect(replay.invocation_id).toBe(first.invocation_id); + await expect( + getAgentInvocation(first.invocation_id), + ).resolves.toMatchObject({ + actor: { platform: "local", userId: "local-user" }, + agentName: "reviewer", + credentialContext: { + actor: { type: "user", userId: "local-user" }, + }, + destination, + destinationVisibility: "private", + input: "Inspect the failing checks.", + parentConversationId, + reasoningLevel: "high", + source: createLocalSource(parentConversationId), + }); + expect(queue.sentRecords()).toHaveLength(1); + await expect( + tool.execute!( + tool.prepareArguments!({ + task: "Start overlapping work.", + name: "reviewer", + reasoning_level: "high", + }), + { toolCallId: "call-2" }, + ), + ).rejects.toMatchObject({ + name: "ToolInputError", + message: + 'Named agent "reviewer" already has active work. Wait for it to finish or use a different name.', + }); + } finally { + await fixture.close(); + } + }); + + it("runs destinationless child work once and persists its terminal result", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAndEnqueueAgentInvocation( + { + ...invocationInput, + idempotencyKey: "execute-1", + }, + { + conversationStore, + nowMs: 2_000, + queue, + state, + }, + ); + const run = vi.fn(async (request) => { + expect(request).toMatchObject({ + conversationId: created.childConversationId, + input: { messageText: invocationInput.input }, + policy: { + authorizationFlowMode: "disabled", + reasoningLevel: "medium", + }, + routing: { + actor: invocationInput.actor, + destination, + destinationVisibility: "private", + source: invocationInput.source, + surface: "internal", + }, + runId: created.invocationId, + }); + await request.durability.onInputCommitted?.(); + return completedAgentRun({ + diagnostics: { + assistantMessageCount: 1, + modelId: "test-model", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + text: "Durable child result", + }); + }); + const fallbackWorker = vi.fn(async () => ({ + status: "completed" as const, + })); + const route = routeAgentInvocationWork({ + fallbackWorker, + invocationWorker: createAgentInvocationWorker({ + agentRunner: { run }, + }), + }); + const queueMessage = queue.takeMessage(); + + await expect( + processConversationQueueMessage(queueMessage, { + conversationStore, + queue, + run: route, + state, + }), + ).resolves.toMatchObject({ status: "completed" }); + await expect( + processConversationQueueMessage(queueMessage, { + conversationStore, + queue, + run: route, + state, + }), + ).resolves.toMatchObject({ status: "no_work" }); + + expect(run).toHaveBeenCalledOnce(); + expect(fallbackWorker).not.toHaveBeenCalled(); + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ + mailboxStatus: "appended", + result: "Durable child result", + status: "completed", + terminalAtMs: expect.any(Number), + }); + const completed = await getAgentInvocation(created.invocationId); + await completeAgentInvocation({ + errorMessage: "late conflicting failure", + invocationId: created.invocationId, + nowMs: Date.now() + 1_000, + status: "failed", + }); + await expect(getAgentInvocation(created.invocationId)).resolves.toEqual( + completed, + ); + } finally { + await fixture.close(); + } + }); + + it("resumes a yielded invocation from durable child state", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAndEnqueueAgentInvocation( + { + ...invocationInput, + idempotencyKey: "resume-1", + }, + { + conversationStore, + nowMs: 2_000, + queue, + state, + }, + ); + let runCount = 0; + const invocationWorker = createAgentInvocationWorker({ + agentRunner: { + run: vi.fn(async (request) => { + runCount += 1; + await request.durability.onInputCommitted?.(); + if (runCount === 1) { + return { resumeVersion: 1, status: "suspended" as const }; + } + return completedAgentRun({ + diagnostics: { + assistantMessageCount: 1, + modelId: "test-model", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + text: "Resumed child result", + }); + }), + }, + }); + const route = routeAgentInvocationWork({ + fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), + invocationWorker, + }); + + await expect( + processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }), + ).resolves.toMatchObject({ status: "yielded" }); + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ status: "awaiting_resume" }); + + await expect( + processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }), + ).resolves.toMatchObject({ status: "completed" }); + expect(runCount).toBe(2); + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ + result: "Resumed child result", + status: "completed", + }); + } finally { + await fixture.close(); + } + }); + + it("repairs the durable creation-to-mailbox crash gap once", async () => { + const { fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAgentInvocation( + { + ...invocationInput, + idempotencyKey: "mailbox-repair-1", + }, + 2_000, + ); + + await recoverPendingAgentInvocationMailboxAppends({ + conversationWorkQueue: queue, + nowMs: 3_000, + }); + await recoverPendingAgentInvocationMailboxAppends({ + conversationWorkQueue: queue, + nowMs: 4_000, + }); + + expect(queue.sentRecords()).toHaveLength(1); + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ mailboxStatus: "appended" }); + } finally { + await fixture.close(); + } + }); + + it("re-parks a stranded running child session before resuming", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAndEnqueueAgentInvocation( + { + ...invocationInput, + idempotencyKey: "running-recovery-1", + }, + { conversationStore, queue, state }, + ); + const turnId = getAgentInvocationTurnId(created.invocationId); + await upsertAgentTurnSessionRecord({ + actor: invocationInput.actor, + conversationId: created.childConversationId, + destination, + modelId: "test-model", + piMessages: [ + { + role: "user", + content: [{ type: "text", text: invocationInput.input }], + timestamp: 1, + }, + ], + sessionId: turnId, + sliceId: 1, + source: invocationInput.source, + state: "running", + surface: "internal", + }); + const run = vi.fn(async (request) => { + await expect( + getAgentTurnSessionRecord(created.childConversationId, turnId), + ).resolves.toMatchObject({ state: "awaiting_resume" }); + await request.durability.onInputCommitted?.(); + return completedAgentRun({ + diagnostics: { + assistantMessageCount: 1, + modelId: "test-model", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + text: "Recovered answer", + }); + }); + const route = routeAgentInvocationWork({ + fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), + invocationWorker: createAgentInvocationWorker({ + agentRunner: { run }, + }), + }); + + await processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }); + + expect(run).toHaveBeenCalledOnce(); + await expect( + conversationStore.get({ + conversationId: created.childConversationId, + }), + ).resolves.not.toHaveProperty("destination"); + } finally { + await fixture.close(); + } + }); + + it("recovers a completed child session without rerunning the agent", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAndEnqueueAgentInvocation( + { + ...invocationInput, + idempotencyKey: "completed-recovery-1", + }, + { conversationStore, queue, state }, + ); + await upsertAgentTurnSessionRecord({ + actor: invocationInput.actor, + conversationId: created.childConversationId, + destination, + modelId: "test-model", + piMessages: [ + { + role: "user", + content: [{ type: "text", text: invocationInput.input }], + }, + { + role: "assistant", + content: [ + { type: "text", text: "Calling a tool" }, + { + type: "toolCall", + id: "tool-1", + name: "lookup", + arguments: {}, + }, + ], + }, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "lookup", + content: [{ type: "text", text: "tool output" }], + isError: false, + }, + { + role: "assistant", + content: [{ type: "text", text: "Recovered visible result" }], + }, + ] as unknown as PiMessage[], + sessionId: getAgentInvocationTurnId(created.invocationId), + sliceId: 1, + source: invocationInput.source, + state: "completed", + surface: "internal", + }); + const run = vi.fn(async () => { + throw new Error("completed sessions must not rerun"); + }); + const route = routeAgentInvocationWork({ + fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), + invocationWorker: createAgentInvocationWorker({ + agentRunner: { run }, + }), + }); + + await expect( + processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }), + ).resolves.toMatchObject({ status: "completed" }); + + expect(run).not.toHaveBeenCalled(); + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ + result: "Recovered visible result", + status: "completed", + }); + } finally { + await fixture.close(); + } + }); + + it("retries runner failures before persisting one final failure", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAndEnqueueAgentInvocation( + { + ...invocationInput, + idempotencyKey: "failure-1", + }, + { conversationStore, queue, state }, + ); + const run = vi.fn(async () => { + throw new Error("model unavailable"); + }); + const deliveryAttempts: Array = []; + const invocationWorker = createAgentInvocationWorker({ + agentRunner: { run }, + }); + const route = routeAgentInvocationWork({ + fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), + invocationWorker: async (context, invocationId) => { + deliveryAttempts.push(context.attempt.messages[0]?.attemptCount); + return await invocationWorker(context, invocationId); + }, + }); + + for ( + let attempt = 1; + attempt <= CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS; + attempt += 1 + ) { + await expect( + processConversationQueueMessage(queue.takeMessage(), { + conversationStore, + queue, + run: route, + state, + }), + ).resolves.toMatchObject({ + status: + attempt === CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS + ? "completed" + : "failed", + }); + } + + expect(run).toHaveBeenCalledTimes( + CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS, + ); + expect(deliveryAttempts).toEqual([undefined, 1, 2, 3, 4]); + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ + errorMessage: "model unavailable", + status: "failed", + }); + } finally { + await fixture.close(); + } + }); + + it("persists invariant failures on the final delivery attempt", async () => { + const { fixture } = await prepareParentConversation(); + try { + const created = await createAgentInvocation({ + ...invocationInput, + agentName: "researcher", + idempotencyKey: "invalid-child-1", + }); + const run = vi.fn(); + const ack = vi.fn(); + const worker = createAgentInvocationWorker({ agentRunner: { run } }); + const context = { + attempt: { + ack, + conversationId: created.childConversationId, + destination, + drain: vi.fn(), + isFinalAttempt: true, + messages: [buildAgentInvocationInboundMessage(created)], + }, + checkIn: vi.fn(), + conversationId: created.childConversationId, + destination, + shouldYield: () => false, + } satisfies ConversationWorkerContext; + + await expect(worker(context, created.invocationId)).resolves.toEqual({ + status: "completed", + }); + + expect(run).not.toHaveBeenCalled(); + expect(ack).toHaveBeenCalledOnce(); + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ + errorMessage: expect.stringContaining( + "must not own a provider destination", + ), + status: "failed", + }); + await expect( + createAgentInvocation({ + ...invocationInput, + agentName: "researcher", + idempotencyKey: "invalid-child-2", + }), + ).resolves.toMatchObject({ + childConversationId: created.childConversationId, + status: "pending", + }); + } finally { + await fixture.close(); + } + }); +}); diff --git a/packages/junior/tests/unit/agent-request.test.ts b/packages/junior/tests/unit/agent-request.test.ts new file mode 100644 index 000000000..bc262e272 --- /dev/null +++ b/packages/junior/tests/unit/agent-request.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { createLocalSource } from "@sentry/junior-plugin-api"; +import { assertRunRoutingConsistency } from "@/chat/agent/request"; + +describe("agent run routing", () => { + it("allows an internal child run to borrow its parent's local route", () => { + expect(() => + assertRunRoutingConsistency({ + conversationId: "local:test:child", + routing: { + destination: { + conversationId: "local:test:parent", + platform: "local", + }, + source: createLocalSource("local:test:parent"), + surface: "internal", + }, + }), + ).not.toThrow(); + }); + + it("rejects the same local route mismatch outside internal work", () => { + expect(() => + assertRunRoutingConsistency({ + conversationId: "local:test:child", + routing: { + destination: { + conversationId: "local:test:parent", + platform: "local", + }, + source: createLocalSource("local:test:parent"), + }, + }), + ).toThrow( + "Local source, destination, and run conversation IDs do not match", + ); + }); + + it("rejects contradictory local parent routing for internal child work", () => { + expect(() => + assertRunRoutingConsistency({ + conversationId: "local:test:child", + routing: { + destination: { + conversationId: "local:test:other-parent", + platform: "local", + }, + source: createLocalSource("local:test:parent"), + surface: "internal", + }, + }), + ).toThrow("Local source and destination conversation IDs do not match"); + }); +}); diff --git a/packages/junior/tests/unit/local/conversation-work.test.ts b/packages/junior/tests/unit/local/conversation-work.test.ts new file mode 100644 index 000000000..7e65269b3 --- /dev/null +++ b/packages/junior/tests/unit/local/conversation-work.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from "vitest"; +import { createLocalConversationWork } from "@/chat/local/conversation-work"; + +describe("local conversation work", () => { + it("does not consume a wake before the producer finishes enqueueing it", async () => { + let processed = false; + const localWork = createLocalConversationWork(async () => { + processed = true; + }); + + await localWork.queue.send({ conversationId: "deferred-start" }); + + expect(processed).toBe(false); + await localWork.drain(); + expect(processed).toBe(true); + }); + + it("deduplicates accepted wakes by idempotency key", async () => { + const processed: string[] = []; + const localWork = createLocalConversationWork(async (message) => { + processed.push(message.conversationId); + }); + + const [first, replay] = await Promise.all([ + localWork.queue.send( + { conversationId: "replayed" }, + { idempotencyKey: "same-wake" }, + ), + localWork.queue.send( + { conversationId: "replayed" }, + { idempotencyKey: "same-wake" }, + ), + ]); + await localWork.drain(); + + expect(replay).toEqual(first); + expect(processed).toEqual(["replayed"]); + }); + + it("runs independent conversation wakes concurrently", async () => { + const started = new Set(); + let release: (() => void) | undefined; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const localWork = createLocalConversationWork(async (message) => { + started.add(message.conversationId); + await blocked; + }); + + await Promise.all([ + localWork.queue.send({ conversationId: "first" }), + localWork.queue.send({ conversationId: "second" }), + ]); + const draining = localWork.drain(); + await vi.waitFor(() => { + expect(started).toEqual(new Set(["first", "second"])); + }); + release?.(); + await draining; + }); + + it("drains accepted work and follow-up wakes", async () => { + const processed: string[] = []; + let releaseFirst: (() => void) | undefined; + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve; + }); + let localWork: ReturnType; + localWork = createLocalConversationWork(async (message) => { + processed.push(message.conversationId); + if (message.conversationId === "first") { + await firstBlocked; + await localWork.queue.send({ conversationId: "follow-up" }); + } + }); + + await localWork.queue.send({ conversationId: "first" }); + const draining = localWork.drain(); + await vi.waitFor(() => { + expect(processed).toEqual(["first"]); + }); + let drained = false; + void draining.then(() => { + drained = true; + }); + await Promise.resolve(); + expect(drained).toBe(false); + + releaseFirst?.(); + await draining; + + expect(processed).toEqual(["first", "follow-up"]); + }); + + it("reports processing failures when drained", async () => { + const localWork = createLocalConversationWork(async () => { + throw new Error("local worker failed"); + }); + + await expect( + localWork.queue.send({ conversationId: "failed" }), + ).resolves.toEqual({ + messageId: "local-conversation-work:1", + }); + await expect(localWork.drain()).rejects.toThrow("local worker failed"); + }); +}); diff --git a/packages/junior/tests/unit/runtime/agent-runner.test.ts b/packages/junior/tests/unit/runtime/agent-runner.test.ts new file mode 100644 index 000000000..db481de3a --- /dev/null +++ b/packages/junior/tests/unit/runtime/agent-runner.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentRunRequest } from "@/chat/agent/request"; +import { createAgentRunner } from "@/chat/runtime/agent-runner"; + +const request = { + conversationId: "local:test:parent", + turnId: "turn-1", + input: { messageText: "Delegate this task." }, + routing: { + destination: { + conversationId: "local:test:parent", + platform: "local", + }, + source: { + conversationId: "local:test:parent", + platform: "local", + type: "priv", + }, + }, +} satisfies AgentRunRequest; + +describe("agent runner controls", () => { + it("binds spawnAgent into the active run durability context", async () => { + const spawnAgent = vi.fn(); + const bindSpawnAgent = vi.fn(() => spawnAgent); + const run = vi.fn(async () => ({ + status: "suspended" as const, + resumeVersion: 1, + })); + const runner = createAgentRunner(run, { bindSpawnAgent }); + + await runner.run(request); + + expect(bindSpawnAgent).toHaveBeenCalledWith(request); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + durability: { spawnAgent }, + }), + ); + }); + + it("does not advertise recursive spawning to delegated runs", async () => { + const bindSpawnAgent = vi.fn(); + const run = vi.fn(async () => ({ + status: "suspended" as const, + resumeVersion: 1, + })); + const runner = createAgentRunner(run, { bindSpawnAgent }); + + await runner.run({ + ...request, + policy: { agentSpawning: "disabled" }, + }); + + expect(bindSpawnAgent).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledWith( + expect.not.objectContaining({ + durability: expect.objectContaining({ spawnAgent: expect.anything() }), + }), + ); + }); +}); diff --git a/packages/junior/tests/unit/tools/spawn-agent.test.ts b/packages/junior/tests/unit/tools/spawn-agent.test.ts new file mode 100644 index 000000000..6aa9b2c3d --- /dev/null +++ b/packages/junior/tests/unit/tools/spawn-agent.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSpawnAgentTool } from "@/chat/tools/runtime/spawn-agent"; +import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; + +describe("spawnAgent", () => { + it("normalizes nullable optional fields before invoking the runtime control", async () => { + const spawnAgent = vi.fn().mockResolvedValue({ + invocationId: "agent-invocation:one", + }); + const tool = createSpawnAgentTool(spawnAgent); + + await expect( + tool.execute!( + tool.prepareArguments!({ + task: "Investigate the failing checks.", + name: null, + reasoning_level: null, + }), + { toolCallId: "call-1" }, + ), + ).resolves.toEqual({ + ok: true, + status: "success", + invocation_id: "agent-invocation:one", + }); + expect(spawnAgent).toHaveBeenCalledWith( + { task: "Investigate the failing checks." }, + { toolCallId: "call-1" }, + ); + }); + + it("requires the runtime tool call id used for durable replay", async () => { + const tool = createSpawnAgentTool(vi.fn()); + + await expect( + tool.execute!( + tool.prepareArguments!({ task: "Investigate the failing checks." }), + {}, + ), + ).rejects.toBeInstanceOf(ToolInputError); + }); +}); diff --git a/policies/interface-design.md b/policies/interface-design.md index 34855b50f..5718eddc3 100644 --- a/policies/interface-design.md +++ b/policies/interface-design.md @@ -46,6 +46,8 @@ Interfaces should expose the smallest useful capability while keeping ownership, arguments, options, or dependencies. Call the owning capability directly unless the intermediate function enforces an invariant, translates a boundary, or owns a lifecycle transition. +- TODO(dcramer): Review why this policy did not prevent the one-method + creator/control wrappers in #1065 and add an enforceable check if practical. - If a helper exists only to hide parameter threading, inline it or move the repeated call shape to the owner that actually defines the contract.