Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/convergence/protocol-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi
| [exported-evidence-and-handoff](#exported-evidence-and-handoff) | `external-durable` | Evidence, verification, report, and handoff maintainers | 35 | true |
| [extension-package-contracts](#extension-package-contracts) | `external-durable` | Skill, Plugin, Hook, and extension maintainers | 48 | true |
| [github-pr-followup-observation](#github-pr-followup-observation) | `projection` | GitHub review and task follow-up maintainers | 1 | false |
| [http-openapi-contract](#http-openapi-contract) | `external-durable` | HTTP/OpenAPI and generated-client maintainers | 136 | true |
| [http-openapi-contract](#http-openapi-contract) | `external-durable` | HTTP/OpenAPI and generated-client maintainers | 137 | true |
| [in-memory-token-session](#in-memory-token-session) | `ephemeral` | Credential and bootstrap maintainers | 1 | false |
| [lsp-process-session](#lsp-process-session) | `ephemeral` | Code intelligence maintainers | 1 | false |
| [mcp-interchange](#mcp-interchange) | `external-durable` | MCP client/server maintainers | 9 | true |
Expand Down Expand Up @@ -986,7 +986,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi
- `pr-followup-http-request-reader` (`v1`, active) at `internal/application/pr_followup.go`
- `pr-followup-http-response-reader` (`v1`, active) at `web/src/api/pr-followup.ts`

<details><summary>136 active identifiers</summary>
<details><summary>137 active identifiers</summary>

- `agent-code-tools.v1`
- `agent_browser_close.v1`
Expand Down Expand Up @@ -1100,6 +1100,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi
- `thread_activity_detail.v2`
- `thread_activity_summary.v1`
- `thread_application_services.v1`
- `thread_branch.v1`
- `thread_command_service_policy.v1`
- `thread_context_compaction.v1`
- `thread_context_compaction_operations.v1`
Expand Down
181 changes: 181 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -28172,6 +28172,129 @@
"x-cyberagent-read-only": false
}
},
"/api/v1/threads/{thread_id}/branches": {
"post": {
"operationId": "branchThread",
"summary": "Branch a conversation at a durable assistant reply",
"description": "Atomically creates a fresh Thread, Run and Session containing conversation history through source_event_id, including earlier Runs. Historical assistant output is non-authorizing evidence. Requires Thread creation and Session message control. No model or tool is called; approvals and runtime authority are reset. Reusing a key with another reply conflicts.",
"tags": [
"Control"
],
"parameters": [
{
"name": "thread_id",
"in": "path",
"description": "Thread identity",
"required": true,
"schema": {
"maxLength": 256,
"minLength": 1,
"pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$",
"type": "string"
}
},
{
"name": "Idempotency-Key",
"in": "header",
"description": "Opaque retry key; only a domain-separated digest is persisted",
"required": true,
"schema": {
"maxLength": 256,
"minLength": 16,
"pattern": "^\\S+$",
"type": "string"
}
}
],
"responses": {
"201": {
"description": "Resource created or idempotently replayed",
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"data": {
"$ref": "#/components/schemas/ThreadBranchView"
},
"request_id": {
"maxLength": 256,
"minLength": 1,
"type": "string"
},
"version": {
"const": "api.v1",
"type": "string"
}
},
"required": [
"version",
"request_id",
"data"
],
"type": "object"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"409": {
"$ref": "#/components/responses/Conflict"
},
"412": {
"$ref": "#/components/responses/FailedPrecondition"
},
"413": {
"$ref": "#/components/responses/RequestEntityTooLarge"
},
"414": {
"$ref": "#/components/responses/RequestTooLarge"
},
"415": {
"$ref": "#/components/responses/UnsupportedMediaType"
},
"429": {
"$ref": "#/components/responses/ResourceExhausted"
},
"500": {
"$ref": "#/components/responses/InternalError"
},
"503": {
"$ref": "#/components/responses/Unavailable"
},
"504": {
"$ref": "#/components/responses/GatewayTimeout"
}
},
"security": [
{
"ControlBearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ThreadBranchRequestView"
}
}
}
},
"x-cyberagent-read-only": false
}
},
"/api/v1/threads/{thread_id}/command-service-policy": {
"get": {
"operationId": "getThreadCommandServicePolicy",
Expand Down Expand Up @@ -67988,6 +68111,64 @@
],
"type": "object"
},
"ThreadBranchRequestView": {
"additionalProperties": false,
"properties": {
"source_event_id": {
"maxLength": 256,
"minLength": 1,
"type": "string"
},
"version": {
"enum": [
"thread_branch.v1"
],
"type": "string"
}
},
"required": [
"version",
"source_event_id"
],
"type": "object"
},
"ThreadBranchView": {
"additionalProperties": false,
"properties": {
"replayed": {
"type": "boolean"
},
"run": {
"$ref": "#/components/schemas/RunView"
},
"session": {
"$ref": "#/components/schemas/SessionView"
},
"source_event_id": {
"maxLength": 256,
"minLength": 1,
"type": "string"
},
"thread": {
"$ref": "#/components/schemas/ThreadView"
},
"version": {
"enum": [
"thread_branch.v1"
],
"type": "string"
}
},
"required": [
"version",
"thread",
"run",
"session",
"source_event_id",
"replayed"
],
"type": "object"
},
"ThreadCommandServicePolicy": {
"additionalProperties": false,
"properties": {
Expand Down
120 changes: 120 additions & 0 deletions internal/application/thread_branch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package application

import (
"context"
"strings"

"cyberagent-workbench/internal/apperror"
"cyberagent-workbench/internal/domain"
"cyberagent-workbench/internal/events"
"cyberagent-workbench/internal/hooks"
"cyberagent-workbench/internal/session"
)

type ThreadBranchStore interface {
GetThread(context.Context, string) (domain.Thread, error)
GetMission(context.Context, string) (domain.Mission, error)
GetRun(context.Context, string) (domain.Run, error)
GetRunMode(context.Context, string) (domain.RunModeSnapshot, error)
GetSession(context.Context, string) (session.Session, error)
GetThreadBranchSource(context.Context, domain.ThreadBranchRequest) (domain.ThreadBranchSource, error)
GetThreadBranchOperation(context.Context, domain.ThreadBranchRequest) (domain.ThreadBranchRecord, bool, error)
CreateThreadBranch(context.Context, domain.ThreadBranchRequest, domain.Mission, domain.Run,
domain.RunModeSnapshot, session.Session, []events.Event) (domain.ThreadBranchRecord, error)
}

type ThreadBranchResult struct {
Thread domain.Thread
Run domain.Run
Session session.Session
SourceEventID string
Replayed bool
}

type ThreadBranchService struct {
store ThreadBranchStore
lifecycleHooks *hooks.Engine
}

func NewThreadBranchService(store ThreadBranchStore) *ThreadBranchService {
return &ThreadBranchService{store: store}
}

func (s *ThreadBranchService) WithLifecycleHooks(engine *hooks.Engine) *ThreadBranchService {
s.lifecycleHooks = engine
return s
}

func (s *ThreadBranchService) Branch(ctx context.Context, request domain.ThreadBranchRequest) (ThreadBranchResult, error) {
if s == nil || s.store == nil {
return ThreadBranchResult{}, apperror.New(apperror.CodeFailedPrecondition, "Thread branch store is unavailable")
}
key, err := domain.NormalizeAgentOperationKey(request.OperationKey)
if err != nil || key != request.OperationKey || strings.ContainsAny(key, " \t\r\n") ||
request.Version != domain.ThreadBranchProtocolVersion || !domain.ValidAgentID(request.ThreadID) ||
!domain.ValidAgentID(request.SourceEventID) || !domain.ValidAgentID(request.RequestedBy) {
return ThreadBranchResult{}, apperror.New(apperror.CodeInvalidArgument, "Thread branch identity or version is invalid")
}
if stored, found, err := s.store.GetThreadBranchOperation(ctx, request); err != nil {
return ThreadBranchResult{}, err
} else if found {
return s.load(ctx, stored)
}
source, err := s.store.GetThreadBranchSource(ctx, request)
if err != nil {
return ThreadBranchResult{}, err
}
run, err := s.store.GetRun(ctx, source.RunID)
if err != nil {
return ThreadBranchResult{}, err
}
mission, err := s.store.GetMission(ctx, run.MissionID)
if err != nil {
return ThreadBranchResult{}, err
}
mode, err := s.store.GetRunMode(ctx, run.ID)
if err != nil {
return ThreadBranchResult{}, err
}
prepared, err := prepareRun(ctx, CreateRunRequest{
Goal: mission.Goal, Profile: string(mission.Profile), WorkspaceID: mission.WorkspaceID,
Surface: string(mode.Surface), Phase: string(mode.Phase), ModelRoute: run.Config.ModelRoute,
Interactive: true, Budget: run.Budget, RequestedBudget: run.Config.RequestedBudget,
RequestedBy: request.RequestedBy, WebAccessMode: string(mode.Scope.WebAccessMode),
WebAllowedTargets: append([]string(nil), mode.Scope.WebAllowedTargets...),
}, nil)
if err != nil {
return ThreadBranchResult{}, apperror.Normalize(err)
}
// Retain the pinned project guidance; execution leases, approvals, continuity
// snapshots and provider-private runtime state belong to their original Run.
prepared.Run.Config.ProjectConfig = append([]byte(nil), run.Config.ProjectConfig...)
prepared.Run.Config.ProjectConfigFingerprint = run.Config.ProjectConfigFingerprint
prepared.Run.Config.ProjectInstructions = append([]byte(nil), run.Config.ProjectInstructions...)
prepared.Run.Config.ProjectInstructionsFingerprint = run.Config.ProjectInstructionsFingerprint
if err := executeLifecycleBoundary(ctx, s.lifecycleHooks, hooks.SessionOpened, prepared.Run.ID,
mission.WorkspaceID, map[string]any{"session_id": prepared.Session.ID, "source": "thread_branch"}); err != nil {
return ThreadBranchResult{}, err
}
stored, err := s.store.CreateThreadBranch(ctx, request, prepared.Mission, prepared.Run, prepared.Mode, prepared.Session, prepared.InitialEvents)
if err != nil {
return ThreadBranchResult{}, apperror.Normalize(err)
}
return s.load(ctx, stored)
}

func (s *ThreadBranchService) load(ctx context.Context, record domain.ThreadBranchRecord) (ThreadBranchResult, error) {
thread, err := s.store.GetThread(ctx, record.ThreadID)
if err != nil {
return ThreadBranchResult{}, err
}
run, err := s.store.GetRun(ctx, record.RunID)
if err != nil {
return ThreadBranchResult{}, err
}
sess, err := s.store.GetSession(ctx, record.SessionID)
if err != nil {
return ThreadBranchResult{}, err
}
return ThreadBranchResult{Thread: thread, Run: run, Session: sess, SourceEventID: record.SourceEventID, Replayed: record.Replayed}, nil
}
Loading
Loading