Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0d48ba2
Scaffold milestone 1: conversation workflow, gateway, worker, chart
DavidNic11 Jul 22, 2026
d41aaae
Group Temporal wiring under internal/temporal with shared client
DavidNic11 Jul 22, 2026
2c9e342
Serve the gateway with gin
DavidNic11 Jul 22, 2026
10eca41
Milestone 2: catalog sync + RBAC-filtered RAG retrieval
DavidNic11 Jul 22, 2026
2d32d09
Milestone 3: tool execution via ToolRun CRs + callback->signal bridge
DavidNic11 Jul 22, 2026
60f21c4
Milestone 4: agent-loop parity in the conversation workflow
DavidNic11 Jul 22, 2026
17f9ac1
Milestone 5: continuation tokens + streamed turn progress
DavidNic11 Jul 22, 2026
633c6ed
Milestone 6: sub-agents as child workflows with HITL signal-waits
DavidNic11 Jul 22, 2026
b48274d
Milestone 7: checkpoint-resume pod agents + identity-link gate
DavidNic11 Jul 22, 2026
caed83b
Add setup-instructions.md
DavidNic11 Jul 22, 2026
380c2f6
Platform deployment kit for bitovi-platform-services
DavidNic11 Jul 22, 2026
4d78639
Add make ecr-push for local image publishing to the platform ECR
DavidNic11 Jul 22, 2026
b446633
Gate Deployments on image.tag; platform-managed ECR; aws --profile pl…
DavidNic11 Jul 22, 2026
632340c
Bump Docker builder to golang:1.26 (go.mod requires >= 1.26.0)
DavidNic11 Jul 22, 2026
ee43c5f
Pin numeric non-root uid 65532 (distroless nonroot)
DavidNic11 Jul 22, 2026
654be5a
Ensure the Temporal namespace on client create
DavidNic11 Jul 23, 2026
51b3d8a
A1: catch the catalog schema up with upstream's CRD additions
DavidNic11 Aug 2, 2026
665db00
A2: deterministic event dispatch via IntegrationRoute
DavidNic11 Aug 2, 2026
50293e9
Record the A2/A3 split as built
DavidNic11 Aug 2, 2026
2c19d27
gofmt the tree
DavidNic11 Aug 2, 2026
0e06c21
A3: /invoke, the event descriptor, and the signed sender assertion
DavidNic11 Aug 2, 2026
602573a
A7: the no-match fallback cascade and the out-of-scope continuity guard
DavidNic11 Aug 2, 2026
44fae85
A4: the authorization pre-flight, with credentials kept out of event …
DavidNic11 Aug 2, 2026
cef7c82
A5: caller-supplied tools over the OpenAI facade
DavidNic11 Aug 3, 2026
d7d6cc3
A6: an agent's own declared tools
DavidNic11 Aug 3, 2026
8afd162
A9 + A8: bridge unmodified upstream pod agents, and record the decisions
DavidNic11 Aug 3, 2026
2fa7a0c
Record that the platform already runs Temporal
DavidNic11 Aug 3, 2026
bd1324b
Add 'engines/temporal/' from commit '2fa7a0c81e2c1b00adb310f049bea8ee…
DavidNic11 Aug 3, 2026
b76420e
B1: import the Temporal engine, with history
DavidNic11 Aug 3, 2026
5cf93c7
Merge main into feat/temporal-engine-import
DavidNic11 Aug 4, 2026
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: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ dist
*.log
coverage
**/*.test.ts

# The Temporal engine is a separate Go module built from its own context
# (engines/temporal), so it is only dead weight in the root context every
# Node image uses.
engines
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,41 @@ jobs:
- name: Test
run: go test ./...

temporal-engine:
name: Go temporal-engine (build, vet, test)
runs-on: ubuntu-latest
defaults:
run:
working-directory: engines/temporal
steps:
- uses: actions/checkout@v5

# Its own job rather than a row in the `go` matrix above: that matrix is
# for modules with no external dependencies and deliberately disables
# setup-go's cache, while this module has a go.sum worth caching.
- uses: actions/setup-go@v6
with:
go-version-file: engines/temporal/go.mod
cache-dependency-path: engines/temporal/go.sum

- name: gofmt
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "::error::These files are not gofmt-clean; run 'gofmt -w .':"
echo "$unformatted"
exit 1
fi

- name: Build
run: go build ./...

- name: Vet
run: go vet ./...

- name: Test
run: go test ./...

core-controller:
name: Go core-controller (lint, test, build)
runs-on: ubuntu-latest
Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ jobs:
- 'tools/ssh/**'
core-controller:
- 'controllers/core-controller/**'
temporal-engine:
- 'engines/temporal/**'
localtool-executor:
- 'sidecars/localtool-executor/**'
charts:
Expand All @@ -132,6 +134,7 @@ jobs:
GITHUB_TOOL: ${{ steps.filter.outputs.github-tool }}
SSH_TOOL: ${{ steps.filter.outputs.ssh-tool }}
CORE_CONTROLLER: ${{ steps.filter.outputs.core-controller }}
TEMPORAL_ENGINE: ${{ steps.filter.outputs.temporal-engine }}
LOCALTOOL_EXECUTOR: ${{ steps.filter.outputs.localtool-executor }}
run: |
cat <<'EOF' > all.json
Expand All @@ -149,6 +152,9 @@ jobs:
{"image":"github","changed_key":"GITHUB_TOOL","dockerfile":"tools/github/Dockerfile","context":"."},
{"image":"ssh","changed_key":"SSH_TOOL","dockerfile":"tools/ssh/Dockerfile","context":"."},
{"image":"core-controller","changed_key":"CORE_CONTROLLER","dockerfile":"controllers/core-controller/Dockerfile","context":"controllers/core-controller"},
{"image":"temporal-engine-worker","changed_key":"TEMPORAL_ENGINE","dockerfile":"engines/temporal/Dockerfile.worker","context":"engines/temporal"},
{"image":"temporal-engine-gateway","changed_key":"TEMPORAL_ENGINE","dockerfile":"engines/temporal/Dockerfile.gateway","context":"engines/temporal"},
{"image":"temporal-engine-catalog-sync","changed_key":"TEMPORAL_ENGINE","dockerfile":"engines/temporal/Dockerfile.catalog-sync","context":"engines/temporal"},
{"image":"localtool-executor-node","changed_key":"LOCALTOOL_EXECUTOR","dockerfile":"sidecars/localtool-executor/Dockerfile","context":"sidecars/localtool-executor","build_args":"BASE_IMAGE=node:24-bookworm-slim\nRUNTIME=node"},
{"image":"localtool-executor-python","changed_key":"LOCALTOOL_EXECUTOR","dockerfile":"sidecars/localtool-executor/Dockerfile","context":"sidecars/localtool-executor","build_args":"BASE_IMAGE=python:3.12-slim-bookworm\nRUNTIME=python"},
{"image":"localtool-executor-go","changed_key":"LOCALTOOL_EXECUTOR","dockerfile":"sidecars/localtool-executor/Dockerfile","context":"sidecars/localtool-executor","build_args":"BASE_IMAGE=golang:1.24-bookworm\nRUNTIME=go"},
Expand Down Expand Up @@ -176,8 +182,9 @@ jobs:
--arg GITHUB_TOOL "$GITHUB_TOOL" \
--arg SSH_TOOL "$SSH_TOOL" \
--arg CORE_CONTROLLER "$CORE_CONTROLLER" \
--arg TEMPORAL_ENGINE "$TEMPORAL_ENGINE" \
--arg LOCALTOOL_EXECUTOR "$LOCALTOOL_EXECUTOR" \
'{AGENT_ORCHESTRATOR:$AGENT_ORCHESTRATOR,OPENCODE_SWE_AGENT:$OPENCODE_SWE_AGENT,CLAUDE_CODE_SWE_AGENT:$CLAUDE_CODE_SWE_AGENT,INTEGRATION_GATEWAY:$INTEGRATION_GATEWAY,RECIPE_SCRAPER:$RECIPE_SCRAPER,RECIPE_PUBLISHER:$RECIPE_PUBLISHER,WEB_SEARCH:$WEB_SEARCH,WEB_FETCH:$WEB_FETCH,KUBECTL_READONLY:$KUBECTL_READONLY,SIGNOZ_QUERY:$SIGNOZ_QUERY,GITHUB_TOOL:$GITHUB_TOOL,SSH_TOOL:$SSH_TOOL,CORE_CONTROLLER:$CORE_CONTROLLER,LOCALTOOL_EXECUTOR:$LOCALTOOL_EXECUTOR}')
'{AGENT_ORCHESTRATOR:$AGENT_ORCHESTRATOR,OPENCODE_SWE_AGENT:$OPENCODE_SWE_AGENT,CLAUDE_CODE_SWE_AGENT:$CLAUDE_CODE_SWE_AGENT,INTEGRATION_GATEWAY:$INTEGRATION_GATEWAY,RECIPE_SCRAPER:$RECIPE_SCRAPER,RECIPE_PUBLISHER:$RECIPE_PUBLISHER,WEB_SEARCH:$WEB_SEARCH,WEB_FETCH:$WEB_FETCH,KUBECTL_READONLY:$KUBECTL_READONLY,SIGNOZ_QUERY:$SIGNOZ_QUERY,GITHUB_TOOL:$GITHUB_TOOL,SSH_TOOL:$SSH_TOOL,CORE_CONTROLLER:$CORE_CONTROLLER,TEMPORAL_ENGINE:$TEMPORAL_ENGINE,LOCALTOOL_EXECUTOR:$LOCALTOOL_EXECUTOR}')
MATRIX=$(jq -c --argjson flags "$FLAGS" 'map(select($flags[.changed_key] == "true"))' all.json)
fi

Expand Down
7 changes: 7 additions & 0 deletions engines/temporal/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
bin/
dist/
*.test
coverage.out
.env
.env.*
.DS_Store
11 changes: 11 additions & 0 deletions engines/temporal/Dockerfile.catalog-sync
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/catalog-sync ./cmd/catalog-sync

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/catalog-sync /catalog-sync
USER 65532:65532
ENTRYPOINT ["/catalog-sync"]
12 changes: 12 additions & 0 deletions engines/temporal/Dockerfile.gateway
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/gateway ./cmd/gateway

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/gateway /gateway
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/gateway"]
11 changes: 11 additions & 0 deletions engines/temporal/Dockerfile.worker
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/worker ./cmd/worker

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/worker /worker
USER 65532:65532
ENTRYPOINT ["/worker"]
37 changes: 37 additions & 0 deletions engines/temporal/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
.PHONY: build test vet docker helm-lint ecr-push

# Local build-and-push to the platform's ECR (no CI: this repo is private/
# local, unlike agent-controller which the platform pipeline builds). The
# ECR repos themselves are platform-managed (Crossplane, created by the
# durable-agents Argo app) — merge the platform PR first, then push.
AWS_PROFILE ?= platform
ECR_ACCOUNT ?= 486491621059
ECR_REGION ?= us-east-1
ECR := $(ECR_ACCOUNT).dkr.ecr.$(ECR_REGION).amazonaws.com
TAG ?= $(shell git rev-parse --short=12 HEAD)

ecr-push:
aws --profile $(AWS_PROFILE) ecr get-login-password --region $(ECR_REGION) | docker login --username AWS --password-stdin $(ECR)
for app in gateway worker catalog-sync; do \
docker build --platform linux/amd64 -f Dockerfile.$$app -t $(ECR)/durable-agents-$$app:$(TAG) . || exit 1; \
docker push $(ECR)/durable-agents-$$app:$(TAG) || exit 1; \
done
@echo ""
@echo "pushed tag: $(TAG) → set gateway/worker/catalogSync image.tag in gitops/durable-agents/values.yaml"

build:
go build ./...

test:
go test ./...

vet:
go vet ./...

docker:
docker build -f Dockerfile.worker -t durable-agents-worker:latest .
docker build -f Dockerfile.gateway -t durable-agents-gateway:latest .
docker build -f Dockerfile.catalog-sync -t durable-agents-catalog-sync:latest .

helm-lint:
helm lint charts/durable-agents
167 changes: 167 additions & 0 deletions engines/temporal/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# durable-agents

> Setup: [setup-instructions.md](setup-instructions.md) — local dev with
> zero cluster, and the full k3s deployment checklist.

AI agents as **Temporal workflows** that can spin up other Temporal-workflow
agents — the successor to
[agent-controller](https://github.com/imaustink/agent-controller)'s
pod-based agent loop. Tools stay as one-shot Kubernetes Jobs (launched via
agent-controller's `ToolRun` CRs), and skill/tool selection stays RAG-based
over Qdrant. See
[docs/adr/0001](docs/adr/0001-agents-as-temporal-workflows.md) for the full
design and milestone plan.

## Components

| Path | What it is |
| ---- | ---------- |
| `cmd/gateway` | Stateless HTTP front door: OpenAI Chat Completions-compatible facade and the `/invoke` accept/poll pair, both → per-session conversation workflow via update-with-start. Also hosts the HMAC callback→signal bridge for tool Jobs, and watches `IntegrationRoute` CRs for deterministic event dispatch. |
| `cmd/worker` | Temporal worker hosting workflows and activities. |
| `cmd/catalog-sync` | Watches agent-controller's Tool/Skill/Agent CRs (dynamic informers) and mirrors them into Qdrant with derived skill access roles. |
| `internal/catalog` | CR decoding, skill-access derivation (ADR 0011 port), indexer. |
| `internal/vectorstore` | Store port + Qdrant adapter; RBAC filters baked into every read. |
| `internal/messaging` | Go port of the tool event stream + HMAC callback contract — tool containers are unchanged. |
| `internal/toolrun` | ToolRun CR launcher (k8s dynamic client) + fake mode for cluster-less dev. |
| `internal/temporal` | Shared Temporal client/config for gateway + worker. |
| `internal/temporal/workflows` | Deterministic workflow code only: `ConversationWorkflow` plus three agent execution styles (declarative, checkpoint-resume, and NATS-bridged upstream pod agents). |
| `internal/agentrun` | Launches upstream `AgentRun` CRs and bridges their bidirectional NATS protocol into workflow signals, so `claude-code-swe-agent` and `opencode-swe-agent` run unmodified. See [docs/pod-agents.md](docs/pod-agents.md). |
| `internal/authz` | The authorization pre-flight: one owner, plain control flow, credentials written to a Secret so a value never enters workflow state. |
| `internal/identitylink` | Client for agent-controller's integration-gateway credential API. |
| `internal/callertools` | Consumer-supplied tools over the OpenAI facade (upstream ADR 0035). |
| `internal/temporal/activities` | All non-deterministic work (LLM calls; later: Qdrant, ToolRun CRs, identity). |
| `internal/llm` | Minimal OpenAI-compatible chat client (base URL overridable). |
| `charts/durable-agents` | Helm chart: gateway + worker. Assumes Temporal is already installed. |

## How a turn flows

1. `POST /v1/chat/completions` (optionally with `X-OpenWebUI-Chat-Id` or
`X-Session-Id` for conversation continuity; bearer token resolved to a
subject + roles, fail closed).
2. The gateway does **update-with-start** on `conversation-<session-id>`:
starts the workflow if absent, then sends the turn as a `user-turn`
Update.
3. The workflow runs the ported agent loop, every LLM/RAG/k8s call an
activity: active-skill fit check (skips retrieval on a hit) → capability
gate (conversational turns answer directly) → RBAC-filtered skill
retrieval from Qdrant → skill selection → resolve the skill's declared
tools → plan ⇄ runTool loop (max 4 steps; tool ids re-validated; ToolRun
CR + durable signal wait per call) → compose the reply around the
verbatim tool result.
4. The workflow idles under a 30-minute timer (then completes) and
continues-as-new after 40 turns to bound event history.

No session store, no Redis, no in-memory pending state — the workflow *is*
the session, including the active skill and continuation tokens.

## Caller-supplied tools

Any OpenAI-compatible client can offer its own functions in the request body
and have them selected alongside the in-cluster catalog (upstream ADR 0035).
The client executes them; this system only decides one fits, returns
`finish_reason: "tool_calls"`, and picks the conversation back up when the
client resends with `role: "tool"` results.

Costs nothing when unused: definitions are keyed by content hash, so identical
tools embed once ever, and a caller sending at most `AGENT_CALLER_TOOL_TOP_K`
(default 5) tools never touches the store at all. Their own `caller_tools`
collection, never the catalog's — one caller's ephemeral definitions must not
enter another's candidate set. A skill can refuse them with
`allowCallerTools: false`.

## Event-driven turns (`/invoke`)

An adapter (agent-controller's integration-gateway) posts
`{request, sessionId, event}` and polls:

```bash
curl -s -XPOST localhost:8080/invoke -H 'Content-Type: application/json' -d '{
"request": "an issue was labeled",
"sessionId": "github:acme/widgets#7",
"event": {"source":"github","event":"issues","action":"labeled",
"labelName":"ai-triage","owner":"acme","repo":"widgets",
"issueNumber":7,"title":"Crash on save"}
}'
# {"id":"conversation-github-acme-widgets-7.<uuid>","status":"pending"}

curl -s localhost:8080/invoke/<id>
# {"id":"...","status":"succeeded","result":"..."}
```

When the `event` matches an `IntegrationRoute` CR, its `promptTemplate` is
rendered and the named Skill/Agent is dispatched directly — no RAG retrieval
(upstream ADR 0024). No match behaves exactly as before the field existed.

The invocation id names a workflow update, so a poll is answered from
Temporal rather than from process memory: any replica can serve it, and a
gateway that dies mid-turn costs the caller nothing. This is upstream's
ADR 0006 restart/scale-out gap, and the "durable invocation records, which
this does not attempt" that ADR 0033 closes on.

`event.senderLogin` says which human triggered the turn, and therefore which
stored credentials the run may receive. Set
`GATEWAY_SENDER_ASSERTION_SECRET` on both this gateway and
integration-gateway to require it signed (`x-gateway-user-assertion`,
wire-compatible with upstream); unset, the unsigned body field is trusted and
both processes warn at startup.

For cluster-less development: `go run ./cmd/dev-seed` populates Qdrant with
a sample catalog, `TOOLRUN_MODE=fake` logs tool launches instead of creating
ToolRun CRs, and you play the tool by posting HMAC-signed events to the
callback listener.

## Local development

Prereqs: Go 1.24+, [Temporal CLI](https://docs.temporal.io/cli), an OpenAI
API key (or any OpenAI-compatible endpoint via `OPENAI_BASE_URL`).

```bash
temporal server start-dev # terminal 1 — Temporal at localhost:7233

export OPENAI_API_KEY=sk-...
go run ./cmd/worker # terminal 2

go run ./cmd/gateway # terminal 3 — listens on :8080

# terminal 4 — two turns in one durable conversation
curl -s localhost:8080/v1/chat/completions \
-H 'Content-Type: application/json' -H 'X-Session-Id: demo' \
-d '{"model":"durable-agents","messages":[{"role":"user","content":"Remember the number 41."}]}'
curl -s localhost:8080/v1/chat/completions \
-H 'Content-Type: application/json' -H 'X-Session-Id: demo' \
-d '{"model":"durable-agents","messages":[{"role":"user","content":"What number did I ask you to remember?"}]}'
```

Inspect the conversation in the Temporal UI (http://localhost:8233):
workflow id `conversation-demo`, query `conversation-state`.

```bash
make build test vet # checks
make docker # build both images
```

## Deploying (k3s)

Assumes a Temporal cluster (e.g. the `temporalio/temporal` chart) is
installed and reachable at `temporal.address`.

```bash
kubectl create namespace durable-agents
kubectl -n durable-agents create secret generic durable-agents-secrets \
--from-literal=OPENAI_API_KEY=<key>

helm install durable-agents charts/durable-agents -n durable-agents \
--set temporal.address=temporal-frontend.temporal.svc:7233
```

Point an OpenAI-compatible client (e.g. Open WebUI, with
`ENABLE_FORWARD_USER_INFO_HEADERS=true` for session continuity) at the
gateway service.

## Roadmap

Milestone plan lives in
[docs/adr/0001](docs/adr/0001-agents-as-temporal-workflows.md#milestones):
catalog/RAG activities → ToolRun execution with callback→signal bridge →
full agent-loop parity → child-workflow sub-agents with HITL signals →
checkpoint-resume pod agents.
6 changes: 6 additions & 0 deletions engines/temporal/charts/durable-agents/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
apiVersion: v2
name: durable-agents
description: Temporal-workflow-based AI agents — gateway + worker. Assumes a Temporal cluster is already installed (e.g. via temporalio/temporal helm chart).
type: application
version: 0.1.0
appVersion: "0.1.0"
33 changes: 33 additions & 0 deletions engines/temporal/charts/durable-agents/templates/_helpers.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{{- define "durable-agents.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{- define "durable-agents.temporalEnv" -}}
- name: TEMPORAL_ADDRESS
value: {{ .Values.temporal.address | quote }}
- name: TEMPORAL_NAMESPACE
value: {{ .Values.temporal.namespace | quote }}
- name: TASK_QUEUE
value: {{ .Values.taskQueue | quote }}
{{- end }}

{{- define "durable-agents.qdrantEnv" -}}
- name: QDRANT_HOST
value: {{ .Values.qdrant.host | quote }}
- name: QDRANT_PORT
value: {{ .Values.qdrant.port | quote }}
{{- with .Values.qdrant.collectionPrefix }}
- name: QDRANT_COLLECTION_PREFIX
value: {{ . | quote }}
{{- end }}
{{- end }}

{{- define "durable-agents.callbackBaseURL" -}}
{{- if .Values.callback.baseURL -}}
{{ .Values.callback.baseURL }}
{{- else -}}
http://{{ .Release.Name }}-gateway-callback.{{ .Release.Namespace }}.svc:8081
{{- end -}}
{{- end }}
Loading