Revert "Planner && task split"

This reverts commit 63002c5f68.
This commit is contained in:
Tienson Qin
2026-03-10 16:14:05 +08:00
parent 63002c5f68
commit 450e7e9a6b
34 changed files with 166 additions and 5520 deletions

View File

@@ -42,12 +42,6 @@ Local split note:
- `worker/wrangler.agents.toml` sets `AGENT_RUNTIME_PROVIDER=e2b` by default.
- On localhost, `/sessions*` forwarding retries during agents startup (up to ~30s) to avoid transient `503`.
Planning note:
- planning APIs live in the same `logseq-agents` service as execution APIs
- current planning entrypoint is `POST /planning/sessions`
- this keeps auth, managed ChatGPT token reuse, runtime dispatch, and future agent types inside one unified agent service
- `/sessions*`, `/planning*`, and future agent endpoints should continue to share the same worker unless there is a clear operational reason to split them
Production routing note:
- If `api.logseq.com` is currently routed via AWS Route53/API Gateway, keep hostname routing in API Gateway.
- Forward only `/sessions*` from API Gateway to the deployed agents worker URL (`*.workers.dev` or another worker-facing domain).

View File

@@ -24,5 +24,3 @@ Milestones are tracked as separate files in this folder:
- `21-m21-store-snapshots-metadata-in-d1.md`
- `23-m23-local-runner-via-tunnel.md`
- `24-m24-e2b-sandbox-runtime-default.md`
- `25-m25-chatgpt-login-token-auth.md`
- `26-m26-cloudflare-native-planning-layer.md`

View File

@@ -1,168 +0,0 @@
# M26: Cloudflare-Native Planning Layer
Status: Proposed
Target: Add a planning control plane on top of the existing `/sessions`
agents service by using Cloudflare Agents for interactive planning chat
and state, and Cloudflare Workflows for durable planning,
approval, execution dispatch, and replanning flows.
## Goal
Introduce a planning layer above the existing agents execution service
while reducing home-made control-plane and planning-chat transport code
as much as possible.
The planning layer should reuse the existing execution substrate rather
than replace it.
The milestone also covers turning planner output into real Logseq
`#Task` blocks and keeping those tasks synchronized with execution
status as sessions run.
## Why M26
- The current agents service already provides the execution substrate for sandbox work and should be reused.
- The missing system layer is durable planning and orchestration, not another execution backend.
- Cloudflare Agents can reduce custom realtime planning chat and planning-session state code.
- Cloudflare Workflows can reduce custom orchestration, retry, wait-state, and replanning logic.
- Planning state and execution-operational state need clearer separation.
## Architectural Decision
- Keep the existing `/sessions` APIs and execution semantics.
- Do not replace E2B, local-runner, runtime-provider, source-control, or checkpoint logic in this milestone.
- Use Cloudflare Agent instances as the canonical planning-session model.
- Use Workflows for direct planning, repo-aware planning, approval waits, execution fan-out, and replanning.
- Keep the Logseq graph as the product-visible system of record for goals, plans, tasks, and execution summaries.
- Accept that operational state remains split across Logseq graph, Cloudflare Agent/Workflow state, and the existing agents service.
## Scope
1. Define a planning-session model backed by Cloudflare Agents.
2. Define a workflow-driven planning pipeline:
- goal intake
- clarification
- direct planning
- repo-aware research
- approval gate
- execution dispatch
- replanning
3. Define planner-created Logseq task persistence:
- create real `#Task` blocks under the goal block by default
- initialize new planner-created tasks as `Todo`
- inherit `project` and `agent` from goal or plan context when available
- use created task `:block/uuid` as the canonical task identity for later reconciliation
4. Define the handoff from planned task to existing `sessions/create`.
5. Define the frontend direction to replace custom planning-chat transport with Cloudflare-native client primitives where possible.
6. Define persistence boundaries across Logseq graph, Agent/Workflow state, and existing execution-operational state.
7. Define an initial rollout strategy that keeps the execution service unchanged.
## Out of Scope
- Replacing `/sessions` execution APIs.
- Replacing sandbox provisioning or runtime-provider logic.
- Replacing checkpoint persistence.
- Replacing managed auth and GitHub token logic.
- Migrating all existing execution chat/session flows to Cloudflare Agents immediately.
- Full frontend rewrite.
## Workstreams
### WS1: Planning Architecture and Contracts
- Define planning entities and lifecycle.
- Define mapping from Goal, Plan, and Task into Logseq task blocks and execution-session payloads.
- Define the planner task contract:
- goal block parent
- task title and content
- `#Task` marker
- initial `Todo` status
- inherited `project`
- inherited `agent`
- optional dependency metadata
- Define approval triggers, follow-up task creation, and replanning entrypoints.
- Define reconciliation rules for replanning:
- match by stored task `:block/uuid` when available
- fall back to best-effort matching against existing non-started child tasks when necessary
- update planning-owned fields before execution starts
- avoid duplicate task creation
- preserve execution-owned fields once a session exists
### WS2: Cloudflare Agent Planning Session
- Define a planning Agent instance as the canonical interactive planning session.
- Use Cloudflare-native chat, state, and client primitives where possible.
- Minimize or eliminate custom planning transport code.
### WS3: Workflow Orchestration
- Define Workflow steps for decomposition, repo-aware research, approval wait, execution dispatch, and replanning.
- Define retry behavior, failure handling, and pause/resume semantics.
- Define how Workflow state links back to Logseq graph records and planning sessions.
### WS4: Logseq Task Persistence
- Create planner-generated tasks as child blocks under the goal block.
- Make planner-created tasks immediately runnable when inherited `project` and `agent` are present.
- Reuse the existing runnable task shape already consumed by the agent session flow.
- Preserve execution-owned fields such as session id, PR URL, checkpoint metadata, and terminal execution status after a task has started.
### WS5: Execution Handoff
- Define exactly how a planned task becomes a `POST /sessions` request.
- Include project metadata, agent metadata, runtime-provider selection, optional runner pinning, checkpoint reuse, and capability flags.
- Reuse the existing execution API instead of inventing a second runtime control surface.
- Reuse the existing session-to-task-status mapping for planner-created tasks:
- `created` / `running` -> `Doing`
- `paused` -> `Todo`
- `completed` -> `Done`
- `pr-created` -> `In Review`
- `failed` / `canceled` -> `Canceled`
- Apply the same execution status sync to planner-created and manually created runnable tasks.
### WS6: Frontend Integration Strategy
- Reuse existing chat UI components where practical.
- Replace custom planning-chat transport with Cloudflare Agent client APIs.
- Keep execution chat/session UI on the existing service unless a later milestone migrates it.
### WS7: Validation and Rollout
- Validate that the planning layer can drive the existing execution backend without adding new custom orchestration endpoints.
- Roll out behind a planning-specific feature flag or internal-only entrypoint.
- Define degraded behavior if Cloudflare Agent or Workflow state is temporarily unavailable.
## Exit Criteria
1. A planning session can be represented as a Cloudflare Agent instance.
2. A planning Workflow can orchestrate decomposition, approval, and execution dispatch durably.
3. Planner-generated tasks appear in Logseq as real `#Task` blocks under the goal block.
4. Planner-created tasks default to `Todo` and inherit `project` and `agent` when available.
5. Replanning updates existing planner-created tasks without creating duplicates.
6. Planned tasks have a documented translation into the existing `sessions/create` contract.
7. The design clearly separates planning state from execution-operational state.
8. The milestone reduces planned custom code by preferring Cloudflare-native planning primitives.
9. Existing `/sessions` execution behavior remains unchanged.
## Validation
- Update the planning architecture document to reflect the new layering and persistence model.
- Review the milestone against the current agents worker contracts before implementation starts.
- Walk through the task-to-`sessions/create` mapping and confirm every required execution field is accounted for.
- Validate planner-created task behavior:
- tasks are created under the goal block
- tasks are marked as `#Task`
- tasks default to `Todo`
- tasks inherit `project` and `agent` from goal or plan context when present
- planner reruns reconcile by stored task `:block/uuid`, with title-based fallback before execution starts
- execution updates status through the existing session-status mapping
- replanning does not overwrite execution-owned fields after a task has started
- Future implementation should add targeted tests for planning-to-execution contract handling and Workflow integration.
## Defaults Chosen
- Planner-created tasks live under the goal block by default.
- Initial planner-created status is `Todo`.
- `project` and `agent` are inherited from goal or plan context when present.
- Planner-created and manual runnable tasks share the same execution status-sync path.
- Created task `:block/uuid` is the canonical task identity after creation.

View File

@@ -27,7 +27,6 @@
"dependencies": {
"@sentry/cloudflare": "^10.38.0",
"@sentry/node": "^10.38.0",
"agents": "^0.7.5",
"better-sqlite3": "^12.6.2",
"e2b": "2.14.0",
"shadow-cljs": "^3.3.4",

View File

@@ -20,9 +20,7 @@
:warnings {:fn-deprecated false
:redef false}}
:modules {:main {:exports {default logseq.agents.worker/worker
AgentSessionDO logseq.agents.worker/AgentSessionDO
PlanningSessionAgent logseq.agents.worker/PlanningSessionAgent
PlanningWorkflow logseq.agents.worker/PlanningWorkflow}}}
AgentSessionDO logseq.agents.worker/AgentSessionDO}}}
:js-options {:js-provider :import}
:closure-defines {shadow.cljs.devtools.client.env/enabled false
goog.debug.LOGGING_ENABLED true}
@@ -52,7 +50,7 @@
:main logseq.agents.e2b-runtime-provider-test-runner/main}
:agents-runtime-transport-test {:target :node-test
:output-to "worker/dist/agents-runtime-transport-test.js"
:ns-regexp "logseq\\.agents\\.(sandbox-test|runtime-provider-test|routes-test|request-test|planning-workflow-test|handler-test)$"
:ns-regexp "logseq\\.agents\\.(sandbox-test|runtime-provider-test)$"
:devtools {:enabled false}
:main logseq.agents.runtime-transport-test-runner/main}
:agents-m25-managed-auth-test {:target :node-test

View File

@@ -17,7 +17,6 @@
(http/json-response :worker/health {:ok true})
(or (string/starts-with? path "/auth")
(string/starts-with? path "/planning")
(string/starts-with? path "/sessions")
(string/starts-with? path "/runners"))
(agent-handler/handle-fetch #js {:env env} request)

View File

@@ -2,7 +2,6 @@
(:require [clojure.string :as string]
[lambdaisland.glogi :as log]
[logseq.agents.checkpoint-store :as checkpoint-store]
[logseq.agents.planning-store :as planning-store]
[logseq.agents.runner-store :as runner-store]
[logseq.agents.runtime-provider :as runtime-provider]
[logseq.agents.sandbox :as sandbox]
@@ -95,7 +94,6 @@
session))
(declare <append-event!)
(declare non-empty-str session-requested-by send-runtime-message!)
(defn- session-task
[session]
@@ -275,176 +273,6 @@
(defn- session-runtime-provider [session]
(some-> (get-in session [:runtime :provider]) str string/lower-case))
(def ^:private session-status->planning-task-status
{"created" "Doing"
"running" "Doing"
"paused" "Todo"
"completed" "Done"
"pr-created" "In Review"
"failed" "Canceled"
"canceled" "Canceled"})
(def ^:private terminal-planning-task-statuses
#{"Done" "In Review" "Canceled"})
(defn- session-planning-session-id
[session]
(some-> (get-in (session-task session) [:source :node-id]) non-empty-str))
(defn- session-planning-task-uuid
[session]
(some-> (get-in (session-task session) [:task-uuid]) non-empty-str))
(defn- task-message-content
[task]
(or (some-> (get-in task [:intent :content]) non-empty-str)
(some-> (:content task) non-empty-str)
(some-> (:description task) non-empty-str)
(some-> (:title task) non-empty-str)))
(defn- planning-task-terminal?
[task]
(contains? terminal-planning-task-statuses
(some-> (:status task) non-empty-str)))
(defn- planning-task-active?
[task]
(or (string? (some-> (:session-id task) non-empty-str))
(contains? #{"Doing" "Done" "In Review" "Canceled"}
(some-> (:status task) non-empty-str))))
(defn- planning-dependencies-satisfied?
[task task-by-uuid]
(let [deps (if (sequential? (:dependencies task))
(keep non-empty-str (:dependencies task))
[])]
(every? (fn [task-uuid]
(planning-task-terminal? (get task-by-uuid task-uuid)))
deps)))
(defn- next-sequenced-task
[tasks current-task-uuid]
(let [tasks (vec (or tasks []))
task-by-uuid (into {}
(keep (fn [task]
(when-let [task-uuid (some-> (:task-uuid task) non-empty-str)]
[task-uuid task])))
tasks)
current-index (or (some (fn [[idx task]]
(when (= current-task-uuid (some-> (:task-uuid task) non-empty-str))
idx))
(map-indexed vector tasks))
-1)]
(some (fn [task]
(when (and (not (planning-task-active? task))
(planning-dependencies-satisfied? task task-by-uuid))
task))
(drop (inc current-index) tasks))))
(defn- update-planning-tasks-for-terminal-event
[tasks current-task-uuid session-id session-status]
(mapv (fn [task]
(if (= current-task-uuid (some-> (:task-uuid task) non-empty-str))
(cond-> (assoc task
:session-id session-id
:status (or (get session-status->planning-task-status session-status)
(:status task)))
(string? (task-message-content task)) (assoc :content (task-message-content task)))
task))
(or tasks [])))
(defn- mark-next-task-doing
[tasks next-task-uuid session-id]
(mapv (fn [task]
(if (= next-task-uuid (some-> (:task-uuid task) non-empty-str))
(assoc task :session-id session-id :status "Doing")
task))
(or tasks [])))
(defn- append-dispatch-session-record
[dispatch-sessions session-id task planning-session]
(let [task-uuid (some-> (:task-uuid task) non-empty-str)]
(if-not (and (string? session-id) (string? task-uuid))
(vec (or dispatch-sessions []))
(let [existing (some #(when (= task-uuid (some-> (:task-uuid %) non-empty-str)) %) dispatch-sessions)
record (or existing
{:id session-id
:task-uuid task-uuid
:source {:node-id (:planning-session-id planning-session)
:node-title (or (some-> planning-session :goal :title non-empty-str)
(some-> planning-session :goal :node-title non-empty-str)
"Planning Task")}
:intent {:content (task-message-content task)}
:project (:project planning-session)
:agent (:agent planning-session)})]
(conj (vec (remove #(= task-uuid (some-> (:task-uuid %) non-empty-str))
(or dispatch-sessions [])))
record)))))
(defn- <continue-planning-session!
[^js self current-session next-task]
(let [message (task-message-content next-task)
user-id (or (session-requested-by current-session) "system")]
(if-not (string? message)
(p/resolved nil)
(p/let [_ (<append-event! self {:type "audit.log"
:data {:event "user-message"
:kind "user"
:by user-id
:message message}
:ts (common/now-ms)})
latest-session (<get-session self)
resumed-session (<maybe-resume-session-for-message! self latest-session user-id)
runtime (:runtime resumed-session)
provider (when (map? runtime)
(runtime-provider/resolve-provider (.-env self) runtime))
_ (send-runtime-message! self
resumed-session
runtime
provider
message
"user")]
true))))
(defn- <advance-planning-session-after-terminal-event!
[^js self session-id session-status]
(p/let [current-session (<get-session self)
planning-session-id (some-> current-session session-planning-session-id)
current-task-uuid (some-> current-session session-planning-task-uuid)]
(when (and (map? current-session)
(= session-id (:id current-session))
(string? planning-session-id)
(string? current-task-uuid)
(contains? #{"completed" "failed" "canceled"} session-status))
(p/let [planning-session (planning-store/<get-planning-session-by-id! (.-env self)
planning-session-id)]
(when (map? planning-session)
(let [tasks (update-planning-tasks-for-terminal-event (get-in planning-session [:plan :tasks])
current-task-uuid
session-id
session-status)
next-task (when (and (= "completed" session-status)
(true? (:auto-dispatch planning-session)))
(next-sequenced-task tasks current-task-uuid))
next-task-uuid (some-> next-task :task-uuid non-empty-str)
tasks (if (string? next-task-uuid)
(mark-next-task-doing tasks next-task-uuid session-id)
tasks)
updated-session (assoc planning-session
:status (if (string? next-task-uuid) "dispatching" (:status planning-session))
:plan (assoc (:plan planning-session) :tasks tasks)
:dispatch-sessions (if (map? next-task)
(append-dispatch-session-record (:dispatch-sessions planning-session)
session-id
next-task
planning-session)
(:dispatch-sessions planning-session)))]
(p/let [_ (planning-store/<upsert-planning-session! (.-env self) updated-session)]
(when (map? next-task)
(<continue-planning-session! self
current-session
next-task)))))))))
(defn- sandbox-bound-runtime?
[runtime]
(and (map? runtime)
@@ -814,15 +642,7 @@
(when (= "session.completed" event-type)
(<checkpoint-and-terminate-completed-runtime! self session-id))
(when (= "session.canceled" event-type)
(<terminate-runtime-on-status! self session-id "canceled"))
(when (contains? #{"session.completed" "session.failed" "session.canceled"} event-type)
(<advance-planning-session-after-terminal-event! self
session-id
(case event-type
"session.completed" "completed"
"session.failed" "failed"
"session.canceled" "canceled"
nil))))))))
(<terminate-runtime-on-status! self session-id "canceled")))))))
(defn- <consume-events-stream! [^js self session-id runtime on-ready]
(let [provider (runtime-provider/resolve-provider (.-env self) runtime)]
@@ -1668,19 +1488,12 @@
;; IMPORTANT: don't block returning the Response; write the initial backlog async
(js/queueMicrotask
(fn []
(-> (p/let [events (<get-events self)
events (session/filter-events events {:since-ts since-ts})]
(doseq [event events]
(when-not @closed?
;; writer.write returns a promise; wait so order is preserved
(p/let [_ (->promise (.write writer (sse-bytes event)))]
nil))))
(p/catch (fn [error]
(when-not @closed?
(log/error :agent/session-stream-backlog-failed
{:error error
:since-ts since-ts})
(cleanup)))))))
(p/let [events (<get-events self)
events (session/filter-events events {:since-ts since-ts})]
(doseq [event events]
;; writer.write returns a promise; wait so order is preserved
(p/let [_ (->promise (.write writer (sse-bytes event)))]
nil)))))
(js/Response.
(.-readable stream)

View File

@@ -2,8 +2,6 @@
(:require [clojure.string :as string]
[lambdaisland.glogi :as log]
[logseq.agents.managed-auth :as managed-auth]
[logseq.agents.planning-store :as planning-store]
[logseq.agents.planning-workflow :as planning-workflow]
[logseq.agents.request :as agent-request]
[logseq.agents.routes :as routes]
[logseq.agents.runner-store :as runner-store]
@@ -45,12 +43,6 @@
[claims]
(aget claims "sub"))
(defn- planning-session-id-from-route
[route]
(some-> (get-in route [:path-params :planning-session-id]) str string/trim not-empty))
(declare planning-agent-binding planning-workflow-binding)
(defn- codex-agent?
[agent]
(cond
@@ -128,8 +120,7 @@
:headers headers})]
(.fetch stub forwarded-request))))
(defn- handle-session-create
[{:keys [env request url claims]} normalize-request]
(defn- handle-create [{:keys [env request url claims]}]
(p/let [result (common/read-json request)]
(if (nil? result)
(http/bad-request "missing body")
@@ -157,593 +148,12 @@
(if-let [^js stub (session-stub env session-id)]
(let [headers (base-headers request claims)
_ (.set headers "x-stream-base" (.-origin url))
task (normalize-request body')
task (agent-request/normalize-session-create body')
body-json (js/JSON.stringify (clj->js task))
do-url (str (.-origin url) "/__session__/init")]
(forward-request stub do-url "POST" headers body-json))
(http/error-response "server error" 500)))))))))
(defn- handle-create [{:keys [env request url claims]}]
(handle-session-create {:env env
:request request
:url url
:claims claims}
agent-request/normalize-session-create))
(defn- normalize-planning-session-create
[body claims]
(let [planning-session-id (or (some-> (:planning-session-id body) str string/trim not-empty)
(some-> (:session-id body) str string/trim not-empty)
(str (random-uuid)))
goal (if (map? (:goal body))
(:goal body)
(cond-> {}
(some-> (:node-id body) str string/trim not-empty) (assoc :node-id (some-> (:node-id body) str string/trim))
(some-> (:node-title body) str string/trim not-empty) (assoc :title (some-> (:node-title body) str string/trim))
(some-> (:content body) str string/trim not-empty) (assoc :description (some-> (:content body) str string/trim))))
require-approval (true? (:require-approval body))
approval-status (if require-approval "pending" "approved")]
{:planning-session-id planning-session-id
:user-id (claims-user-id claims)
:status "active"
:goal goal
:project (when (map? (:project body)) (:project body))
:agent (:agent body)
:approval-status approval-status
:require-approval require-approval
:auto-dispatch (if (boolean? (:auto-dispatch body))
(:auto-dispatch body)
false)
:auto-replan (true? (:auto-replan body))
:replan-delay-sec (if (number? (:replan-delay-sec body))
(max 0 (:replan-delay-sec body))
0)}))
(defn- handle-planning-create [{:keys [env request claims]}]
(cond
(not (planning-agent-binding env))
(http/error-response "planning chat transport unavailable" 503)
(not (planning-store/available? env))
(http/error-response "planning state unavailable" 503)
:else
(p/let [result (common/read-json request)]
(if (nil? result)
(http/bad-request "missing body")
(p/let [body' (js->clj result :keywordize-keys true)
session (normalize-planning-session-create body' claims)
planning-session-id (:planning-session-id session)
user-id (:user-id session)
params (agent-request/normalize-planning-workflow-create
(merge body'
{:planning-session-id planning-session-id
:user-id user-id
:goal (:goal session)
:project (:project session)
:agent (:agent session)
:require-approval (:require-approval session)
:auto-dispatch (:auto-dispatch session)
:auto-replan (:auto-replan session)
:replan-delay-sec (:replan-delay-sec session)}))
params (when (map? params)
(planning-workflow/enrich-params-with-model-plan env
params
{:timeout-ms 2500}))
orchestrated (when (map? params)
(planning-workflow/orchestrate-response params))]
(if-not (and (string? planning-session-id)
(string? user-id)
(map? params)
(map? orchestrated))
(http/bad-request "invalid body")
(-> (planning-store/<upsert-planning-session! env
(assoc session
:status (:status orchestrated)
:goal (:goal params)
:project (:project params)
:agent (:agent params)
:plan (assoc (:plan orchestrated)
:tasks (:reconciled-tasks orchestrated))
:scheduled-actions (:scheduled-actions orchestrated)
:dispatch-sessions (:dispatch-sessions orchestrated)))
(p/then (fn [_]
(http/json-response :planning.sessions/create
{:planning-session-id planning-session-id
:status (:status orchestrated)
:plan (assoc (:plan orchestrated)
:tasks (:reconciled-tasks orchestrated))
:scheduled-actions (:scheduled-actions orchestrated)
:dispatch-sessions (:dispatch-sessions orchestrated)
:approval-status (:approval-status session)
:require-approval (true? (:require-approval session))
:auto-dispatch (true? (:auto-dispatch session))
:auto-replan (true? (:auto-replan session))
:replan-delay-sec (:replan-delay-sec session)
:chat-path (str "/planning/chat/" planning-session-id)})))
(p/catch (fn [error]
(log/error :agent/planning-session-create-failed error)
(http/error-response "failed to create planning session" 500))))))))))
(defn- planning-workflow-binding [^js env]
(aget env "PLANNING_WORKFLOW"))
(defn- planning-agent-binding [^js env]
(aget env "PLANNING_AGENT"))
(defn- planning-agent-stub
[^js env planning-session-id]
(when-let [^js namespace (planning-agent-binding env)]
(let [do-id (.idFromName namespace planning-session-id)]
(.get namespace do-id))))
(def ^:private planning-agent-namespace "planning-agent")
(defn- planning-agent-request
[request planning-session-id]
(let [headers (js/Headers. (.-headers request))]
(.set headers "x-partykit-room" planning-session-id)
(.set headers "x-partykit-namespace" planning-agent-namespace)
(js/Request. (.-url request)
#js {:method (.-method request)
:headers headers
:body (when-not (= "GET" (.-method request))
(.-body request))
:duplex "half"})))
(defn- handle-planning-workflow-create [{:keys [env request claims]}]
(cond
(not (planning-workflow-binding env))
(http/error-response "planning workflow unavailable" 503)
(not (planning-store/available? env))
(http/error-response "planning state unavailable" 503)
:else
(p/let [result (common/read-json request)]
(if (nil? result)
(http/bad-request "missing body")
(p/let [user-id (claims-user-id claims)
body' (js->clj result :keywordize-keys true)
params (agent-request/normalize-planning-workflow-create body')
planning-session-id (or (:planning-session-id params)
(str (random-uuid)))
params (cond-> params
(string? planning-session-id) (assoc :planning-session-id planning-session-id)
(string? user-id) (assoc :user-id user-id))
params (when (map? params)
(planning-workflow/enrich-params-with-model-plan env params))
workflow-id (or (:workflow-id params)
(str planning-session-id "-workflow"))
workflow-binding (planning-workflow-binding env)]
(if-not (and (map? params)
(string? user-id)
(string? planning-session-id))
(http/bad-request "invalid body")
(-> (.create workflow-binding
(clj->js {:id workflow-id
:params (dissoc params :workflow-id)}))
(p/then (fn [instance]
(let [workflow-id (or (some-> instance .-id)
workflow-id)]
(-> (planning-store/<upsert-planning-session! env
{:planning-session-id planning-session-id
:user-id user-id
:workflow-id workflow-id
:status (if (true? (:require-approval params))
"waiting-approval"
"queued")
:goal (:goal params)
:project (:project params)
:agent (:agent params)
:plan (assoc (:plan params)
:tasks (:tasks params))
:approval-status (if (true? (:require-approval params))
"pending"
"approved")
:require-approval (true? (:require-approval params))
:auto-dispatch (if (boolean? (:auto-dispatch params))
(:auto-dispatch params)
true)
:auto-replan (true? (:auto-replan params))
:replan-delay-sec (:replan-delay-sec params)})
(p/then (fn [_]
(http/json-response :planning.workflows/create
{:planning-session-id planning-session-id
:workflow-id workflow-id
:status "queued"})))))))
(p/catch (fn [error]
(log/error :agent/planning-workflow-create-failed error)
(http/error-response "failed to create planning workflow" 500))))))))))
(defn- handle-planning-workflow-get [{:keys [env route]}]
(let [workflow-id (get-in route [:path-params :workflow-id])
workflow-binding (planning-workflow-binding env)]
(cond
(not workflow-binding)
(http/error-response "planning workflow unavailable" 503)
(not (string? workflow-id))
(http/bad-request "invalid workflow id")
:else
(-> (.get workflow-binding workflow-id)
(p/then (fn [instance]
(if-not instance
(http/not-found)
(-> (.status instance)
(p/then (fn [status]
(http/json-response :planning.workflows/get
{:workflow-id workflow-id
:status status})))))))
(p/catch (fn [error]
(log/error :agent/planning-workflow-get-failed error)
(http/error-response "failed to fetch planning workflow" 500)))))))
(defn- <get-owned-planning-session!
[^js env user-id planning-session-id]
(if (and (planning-store/available? env)
(string? user-id)
(string? planning-session-id))
(planning-store/<get-planning-session! env user-id planning-session-id)
(p/resolved nil)))
(defn- <get-planning-agent-state!
[^js env planning-session-id]
(if-let [^js stub (planning-agent-stub env planning-session-id)]
(let [request (platform/request "https://planning.internal/__planning__/state"
#js {:method "GET"})]
(-> (.fetch stub (planning-agent-request request planning-session-id))
(p/then (fn [response]
(if-not (.-ok response)
nil
(-> (.json response)
(p/then (fn [payload]
(let [state (js->clj payload :keywordize-keys true)]
(when (map? state)
state))))))))
(p/catch (fn [_]
nil))))
(p/resolved nil)))
(defn- workflow-event-payload
[event-type approval-status]
(let [approved? (= "approved" approval-status)]
{:type event-type
:payload {:approved approved?
:decision approval-status}}))
(defn- <send-workflow-event!
[workflow-binding workflow-id event]
(if-not (string? workflow-id)
(p/rejected (ex-info "invalid workflow id" {:workflow-id workflow-id}))
(-> (.get workflow-binding workflow-id)
(p/then (fn [instance]
(if-not instance
(p/rejected (ex-info "workflow not found" {:workflow-id workflow-id}))
(let [send-event (aget instance "sendEvent")]
(if (fn? send-event)
(.call send-event instance (clj->js event))
(p/rejected (ex-info "workflow event transport unavailable"
{:workflow-id workflow-id}))))))))))
(defn- planning-session-status-for-approval
[approval-status]
(case approval-status
"approved" "queued"
"rejected" "rejected"
"pending" "waiting-approval"
"waiting-approval"))
(defn- normalize-task-binding
[binding]
(when (map? binding)
(let [task-uuid (some-> (:task-uuid binding) str string/trim not-empty)
block-uuid (some-> (:block-uuid binding) str string/trim not-empty)
session-id (some-> (:session-id binding) str string/trim not-empty)]
(when (and task-uuid block-uuid)
(cond-> {:task-uuid task-uuid
:block-uuid block-uuid}
(string? session-id) (assoc :session-id session-id))))))
(defn- merge-task-bindings
[tasks bindings]
(let [binding-by-task-uuid (->> bindings
(keep normalize-task-binding)
(reduce (fn [acc binding]
(assoc acc (:task-uuid binding) binding))
{}))]
(mapv (fn [task]
(if-let [binding (get binding-by-task-uuid
(some-> (:task-uuid task) str string/trim not-empty))]
(cond-> (assoc task :block-uuid (:block-uuid binding))
(string? (:session-id binding)) (assoc :session-id (:session-id binding)
:status (or (:status task) "Doing")))
task))
(or tasks []))))
(defn- handle-planning-session-get [{:keys [env claims route]}]
(let [user-id (claims-user-id claims)
planning-session-id (planning-session-id-from-route route)]
(cond
(not (planning-store/available? env))
(http/error-response "planning state unavailable" 503)
(not (string? user-id))
(http/unauthorized)
(not (string? planning-session-id))
(http/bad-request "invalid planning session id")
:else
(p/let [planning-session (<get-owned-planning-session! env
user-id
planning-session-id)]
(if-not (map? planning-session)
(http/not-found)
(http/json-response :planning.sessions/get
planning-session))))))
(defn- approval-decision
[body]
(or (some-> (get-in body [:approval :decision]) str string/trim string/lower-case not-empty)
(some-> (:decision body) str string/trim string/lower-case not-empty)))
(defn- handle-planning-session-approval [{:keys [env request claims route]}]
(let [user-id (claims-user-id claims)
planning-session-id (planning-session-id-from-route route)
workflow-binding (planning-workflow-binding env)]
(cond
(not (planning-store/available? env))
(http/error-response "planning state unavailable" 503)
(not (string? user-id))
(http/unauthorized)
(not (string? planning-session-id))
(http/bad-request "invalid planning session id")
:else
(p/let [result (common/read-json request)
body (if (nil? result) {} (js->clj result :keywordize-keys true))
planning-state (<get-planning-agent-state! env planning-session-id)
approval-status (approval-decision body)
approval-status (if (contains? #{"pending" "approved" "rejected"} approval-status)
approval-status
"pending")
existing (<get-owned-planning-session! env user-id planning-session-id)]
(if-not (map? existing)
(http/not-found)
(let [workflow-id (some-> (:workflow-id existing) str string/trim not-empty)]
(cond
(and workflow-binding (string? workflow-id))
(-> (<send-workflow-event! workflow-binding
workflow-id
(workflow-event-payload "approval" approval-status))
(p/then (fn [_]
(planning-store/<update-planning-session! env
user-id
planning-session-id
{:approval-status approval-status
:status (planning-session-status-for-approval approval-status)})))
(p/then (fn [updated]
(if-not (map? updated)
(http/not-found)
(http/json-response :planning.sessions/get updated))))
(p/catch (fn [error]
(log/error :agent/planning-session-approval-event-failed error)
(http/error-response "failed to apply planning approval" 500))))
(and (= "approved" approval-status)
workflow-binding)
(p/let [params (planning-workflow/enrich-params-with-model-plan
env
{:planning-session-id planning-session-id
:user-id user-id
:goal (:goal existing)
:project (:project existing)
:agent (:agent existing)
:tasks (get-in existing [:plan :tasks])
:planning-messages (:messages planning-state)
:require-approval (true? (:require-approval existing))
:approval {:decision "approved"
:comment (some-> (get-in body [:approval :comment])
str
string/trim
not-empty)}
:auto-dispatch (if (boolean? (:auto-dispatch existing))
(:auto-dispatch existing)
true)
:auto-replan (true? (:auto-replan existing))
:replan-delay-sec (:replan-delay-sec existing)})
workflow-id (or workflow-id
(str planning-session-id "-workflow"))]
(-> (.create workflow-binding
(clj->js {:id workflow-id
:params params}))
(p/then (fn [instance]
(let [workflow-id (or (some-> instance .-id)
workflow-id)]
(planning-store/<update-planning-session! env
user-id
planning-session-id
{:approval-status "approved"
:workflow-id workflow-id
:status "queued"}))))
(p/then (fn [updated]
(if-not (map? updated)
(http/not-found)
(http/json-response :planning.sessions/get updated))))
(p/catch (fn [error]
(log/error :agent/planning-session-approval-failed error)
(http/error-response "failed to queue approved planning workflow" 500)))))
:else
(p/let [updated (planning-store/<update-planning-session! env
user-id
planning-session-id
{:approval-status approval-status
:status (planning-session-status-for-approval approval-status)})]
(if-not (map? updated)
(http/not-found)
(http/json-response :planning.sessions/get updated))))))))))
(defn- handle-planning-session-task-sync [{:keys [env request claims route]}]
(let [user-id (claims-user-id claims)
planning-session-id (planning-session-id-from-route route)]
(cond
(not (planning-store/available? env))
(http/error-response "planning state unavailable" 503)
(not (string? user-id))
(http/unauthorized)
(not (string? planning-session-id))
(http/bad-request "invalid planning session id")
:else
(p/let [existing (<get-owned-planning-session! env user-id planning-session-id)
result (common/read-json request)]
(if-not (map? existing)
(http/not-found)
(let [body (if (nil? result) {} (js->clj result :keywordize-keys true))
bindings (if (sequential? (:tasks body))
(vec (:tasks body))
[])
merged-tasks (merge-task-bindings (get-in existing [:plan :tasks]) bindings)
updated-session (assoc existing :plan (assoc (:plan existing) :tasks merged-tasks))]
(p/let [updated (planning-store/<upsert-planning-session! env updated-session)]
(http/json-response :planning.sessions/get
(or updated
updated-session)))))))))
(defn- handle-planning-session-replan [{:keys [env request claims route]}]
(let [workflow-binding (planning-workflow-binding env)
user-id (claims-user-id claims)
planning-session-id (planning-session-id-from-route route)]
(cond
(not workflow-binding)
(http/error-response "planning workflow unavailable" 503)
(not (planning-store/available? env))
(http/error-response "planning state unavailable" 503)
(not (string? user-id))
(http/unauthorized)
(not (string? planning-session-id))
(http/bad-request "invalid planning session id")
:else
(p/let [existing (<get-owned-planning-session! env user-id planning-session-id)
result (common/read-json request)
planning-state (<get-planning-agent-state! env planning-session-id)]
(if-not (map? existing)
(http/not-found)
(let [body (if (nil? result)
{}
(js->clj result :keywordize-keys true))
params (agent-request/normalize-planning-workflow-create
(merge {:planning-session-id planning-session-id
:user-id user-id
:goal (:goal existing)
:project (:project existing)
:agent (:agent existing)
:planning-messages (:messages planning-state)
:require-approval (:require-approval existing)
:auto-dispatch (:auto-dispatch existing)
:auto-replan (:auto-replan existing)
:replan-delay-sec (:replan-delay-sec existing)}
body))
params (when (map? params)
(planning-workflow/enrich-params-with-model-plan env params))
existing-workflow-id (some-> (:workflow-id existing) str string/trim not-empty)
workflow-id (or (:workflow-id params)
existing-workflow-id
(str planning-session-id "-replan-" (random-uuid)))
plan-tasks (or (:tasks params)
(get-in existing [:plan :tasks]))
update-data {:workflow-id workflow-id
:status (if (true? (:require-approval params))
"waiting-approval"
"queued")
:plan {:tasks plan-tasks}
:approval-status (if (true? (:require-approval params))
"pending"
"approved")
:require-approval (true? (:require-approval params))
:auto-dispatch (if (boolean? (:auto-dispatch params))
(:auto-dispatch params)
true)
:auto-replan (true? (:auto-replan params))
:replan-delay-sec (:replan-delay-sec params)}]
(if (string? existing-workflow-id)
(-> (<send-workflow-event! workflow-binding
existing-workflow-id
{:type "replan"
:payload {:params (assoc (dissoc params :workflow-id)
:workflow-id existing-workflow-id)}})
(p/then (fn [_]
(planning-store/<update-planning-session! env
user-id
planning-session-id
(assoc update-data :workflow-id existing-workflow-id))))
(p/then (fn [updated]
(http/json-response :planning.sessions/get
(or updated
existing))))
(p/catch (fn [error]
(log/error :agent/planning-session-replan-event-failed error)
(http/error-response "failed to queue replanning workflow" 500))))
(-> (.create workflow-binding
(clj->js {:id workflow-id
:params (assoc (dissoc params :workflow-id)
:workflow-id workflow-id)}))
(p/then (fn [instance]
(let [workflow-id (or (some-> instance .-id)
workflow-id)]
(planning-store/<update-planning-session! env
user-id
planning-session-id
(assoc update-data :workflow-id workflow-id)))))
(p/then (fn [updated]
(http/json-response :planning.sessions/get
(or updated
existing))))
(p/catch (fn [error]
(log/error :agent/planning-session-replan-failed error)
(http/error-response "failed to queue replanning workflow" 500)))))))))))
(defn- handle-planning-chat-transport [{:keys [env request claims route]}]
(let [planning-agent (planning-agent-binding env)
planning-session-id (planning-session-id-from-route route)
user-id (claims-user-id claims)]
(cond
(not planning-agent)
(http/error-response "planning chat transport unavailable" 503)
(not (planning-store/available? env))
(http/error-response "planning state unavailable" 503)
(not (string? user-id))
(http/unauthorized)
(not (string? planning-session-id))
(http/bad-request "invalid planning session id")
:else
(p/let [owned-session (<get-owned-planning-session! env
user-id
planning-session-id)]
(if-not (map? owned-session)
(http/not-found)
(if-let [^js stub (planning-agent-stub env planning-session-id)]
(-> (.fetch stub (planning-agent-request request planning-session-id))
(p/catch (fn [error]
(log/error :agent/planning-chat-forward-failed error)
(http/error-response "planning chat transport failed" 500))))
(http/error-response "planning chat transport unavailable" 503)))))))
(defn- handle-auth-status [{:keys [env claims]}]
(let [user-id (claims-user-id claims)]
(if-not (string? user-id)
@@ -975,14 +385,6 @@
(case (:handler route)
:auth.chatgpt/import (handle-auth-import ctx)
:auth.chatgpt/status (handle-auth-status ctx)
:planning.sessions/create (handle-planning-create ctx)
:planning.sessions/get (handle-planning-session-get ctx)
:planning.sessions/approval (handle-planning-session-approval ctx)
:planning.sessions/tasks.sync (handle-planning-session-task-sync ctx)
:planning.sessions/replan (handle-planning-session-replan ctx)
:planning.chat/transport (handle-planning-chat-transport ctx)
:planning.workflows/create (handle-planning-workflow-create ctx)
:planning.workflows/get (handle-planning-workflow-get ctx)
:sessions/create (handle-create ctx)
:sessions/get (handle-get ctx)
:sessions/messages (handle-messages ctx)

View File

@@ -1,72 +0,0 @@
(ns logseq.agents.planning-agent
(:require [clojure.string :as string]
[logseq.sync.common :as common]
[logseq.sync.platform.core :as platform]))
(defn- non-empty-str
[value]
(when (string? value)
(let [trimmed (string/trim value)]
(when-not (string/blank? trimmed)
trimmed))))
(defn default-state
[planning-session-id]
{:planning-session-id planning-session-id
:messages []
:updated-at (common/now-ms)})
(defn- ensure-state-shape
[planning-session-id state]
(let [messages (if (sequential? (:messages state))
(vec (:messages state))
[])]
(cond-> {:planning-session-id planning-session-id
:messages messages
:updated-at (common/now-ms)}
(map? state) (merge (dissoc state :messages :updated-at :planning-session-id)))))
(defn append-message
[planning-session-id state role content]
(let [content (non-empty-str content)
state (ensure-state-shape planning-session-id state)]
(if-not (string? content)
state
(update (assoc state :updated-at (common/now-ms))
:messages
(fnil conj [])
{:id (str (random-uuid))
:role role
:content content
:ts (common/now-ms)}))))
(defn normalize-state
[planning-session-id state]
(ensure-state-shape planning-session-id state))
(defn parse-message-content
[payload]
(cond
(string? payload)
(or (try
(some-> (js/JSON.parse payload)
(js->clj :keywordize-keys true)
:content
non-empty-str)
(catch :default _
nil))
(non-empty-str payload))
(map? payload)
(or (some-> (:content payload) non-empty-str)
(some-> (:message payload) non-empty-str))
:else
nil))
(defn json-response
[data status]
(platform/response
(js/JSON.stringify (clj->js data))
#js {:status status
:headers #js {"content-type" "application/json"}}))

View File

@@ -1,219 +0,0 @@
(ns logseq.agents.planning-store
(:require [clojure.string :as string]
[logseq.sync.common :as common]
[promesa.core :as p]))
(defn- non-empty-str
[value]
(when (string? value)
(let [trimmed (string/trim value)]
(when-not (string/blank? trimmed)
trimmed))))
(defn- normalize-int
[value default-value]
(let [parsed (if (number? value)
value
(some-> value str js/parseInt))]
(if (and (number? parsed)
(not (js/isNaN parsed)))
parsed
default-value)))
(defn- bool->int
[value]
(if (true? value) 1 0))
(defn- int->bool
[value]
(pos? (normalize-int value 0)))
(defn- parse-json
[value default-value]
(if-let [raw (non-empty-str value)]
(try
(js->clj (js/JSON.parse raw) :keywordize-keys true)
(catch :default _
default-value))
default-value))
(defn- serialize-json
[value]
(when (some? value)
(js/JSON.stringify (clj->js value))))
(defn- db-binding
[^js env]
(aget env "AGENTS_DB"))
(defn available?
[^js env]
(boolean (db-binding env)))
(declare <get-planning-session!)
(declare <get-planning-session-by-id!)
(defn- row->planning-session
[row]
(when row
(let [planning-session-id (aget row "planning_session_id")
user-id (aget row "user_id")
workflow-id (non-empty-str (aget row "workflow_id"))
status (or (non-empty-str (aget row "status")) "queued")
approval-status (or (non-empty-str (aget row "approval_status")) "pending")
require-approval (int->bool (aget row "require_approval"))
auto-dispatch (int->bool (aget row "auto_dispatch"))
auto-replan (int->bool (aget row "auto_replan"))
replan-delay-sec (normalize-int (aget row "replan_delay_sec") 0)
created-at (normalize-int (aget row "created_at") 0)
updated-at (normalize-int (aget row "updated_at") 0)
last-error (non-empty-str (aget row "last_error"))
goal (parse-json (aget row "goal_json") nil)
plan (parse-json (aget row "plan_json") nil)
project (parse-json (aget row "project_json") nil)
agent (parse-json (aget row "agent_json") nil)
scheduled-actions (parse-json (aget row "scheduled_actions_json") [])
dispatch-sessions (parse-json (aget row "dispatch_sessions_json") [])]
(when (and (string? planning-session-id)
(string? user-id))
(cond-> {:planning-session-id planning-session-id
:user-id user-id
:status status
:approval-status approval-status
:require-approval require-approval
:auto-dispatch auto-dispatch
:auto-replan auto-replan
:replan-delay-sec replan-delay-sec
:scheduled-actions (if (sequential? scheduled-actions)
(vec scheduled-actions)
[])
:dispatch-sessions (if (sequential? dispatch-sessions)
(vec dispatch-sessions)
[])
:created-at created-at
:updated-at updated-at}
(string? workflow-id) (assoc :workflow-id workflow-id)
(map? goal) (assoc :goal goal)
(map? plan) (assoc :plan plan)
(map? project) (assoc :project project)
(some? agent) (assoc :agent agent)
(string? last-error) (assoc :last-error last-error))))))
(defn <upsert-planning-session!
[^js env planning-session]
(if-let [db (db-binding env)]
(let [planning-session-id (some-> (:planning-session-id planning-session) non-empty-str)
user-id (some-> (:user-id planning-session) non-empty-str)
workflow-id (some-> (:workflow-id planning-session) non-empty-str)
status (or (some-> (:status planning-session) non-empty-str) "queued")
approval-status (or (some-> (:approval-status planning-session) non-empty-str)
(if (true? (:require-approval planning-session)) "pending" "approved"))
require-approval (bool->int (true? (:require-approval planning-session)))
auto-dispatch (bool->int (if (boolean? (:auto-dispatch planning-session))
(:auto-dispatch planning-session)
true))
auto-replan (bool->int (true? (:auto-replan planning-session)))
replan-delay-sec (max 0 (normalize-int (:replan-delay-sec planning-session) 0))
now (common/now-ms)]
(if (and (string? planning-session-id)
(string? user-id))
(p/let [_ (common/<d1-run db
(str "insert into planning_sessions "
"(planning_session_id, user_id, workflow_id, status, goal_json, plan_json, project_json, agent_json, "
"approval_status, require_approval, auto_dispatch, auto_replan, replan_delay_sec, "
"scheduled_actions_json, dispatch_sessions_json, last_error, created_at, updated_at) "
"values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"on conflict(planning_session_id) do update set "
"user_id = excluded.user_id, "
"workflow_id = excluded.workflow_id, "
"status = excluded.status, "
"goal_json = excluded.goal_json, "
"plan_json = excluded.plan_json, "
"project_json = excluded.project_json, "
"agent_json = excluded.agent_json, "
"approval_status = excluded.approval_status, "
"require_approval = excluded.require_approval, "
"auto_dispatch = excluded.auto_dispatch, "
"auto_replan = excluded.auto_replan, "
"replan_delay_sec = excluded.replan_delay_sec, "
"scheduled_actions_json = excluded.scheduled_actions_json, "
"dispatch_sessions_json = excluded.dispatch_sessions_json, "
"last_error = excluded.last_error, "
"updated_at = excluded.updated_at")
planning-session-id
user-id
workflow-id
status
(serialize-json (:goal planning-session))
(serialize-json (:plan planning-session))
(serialize-json (:project planning-session))
(serialize-json (:agent planning-session))
approval-status
require-approval
auto-dispatch
auto-replan
replan-delay-sec
(serialize-json (:scheduled-actions planning-session))
(serialize-json (:dispatch-sessions planning-session))
(some-> (:last-error planning-session) non-empty-str)
now
now)
stored (<get-planning-session-by-id! env planning-session-id)]
stored)
(p/resolved nil)))
(p/resolved nil)))
(defn <get-planning-session-by-id!
[^js env planning-session-id]
(if-let [db (db-binding env)]
(if-let [planning-session-id (some-> planning-session-id non-empty-str)]
(p/let [result (common/<d1-all db
(str "select planning_session_id, user_id, workflow_id, status, goal_json, plan_json, project_json, "
"agent_json, approval_status, require_approval, auto_dispatch, auto_replan, replan_delay_sec, "
"scheduled_actions_json, dispatch_sessions_json, last_error, created_at, updated_at "
"from planning_sessions where planning_session_id = ? limit 1")
planning-session-id)
rows (common/get-sql-rows result)]
(row->planning-session (first rows)))
(p/resolved nil))
(p/resolved nil)))
(defn <get-planning-session!
[^js env user-id planning-session-id]
(if-let [db (db-binding env)]
(if-let [user-id (some-> user-id non-empty-str)]
(if-let [planning-session-id (some-> planning-session-id non-empty-str)]
(p/let [result (common/<d1-all db
(str "select planning_session_id, user_id, workflow_id, status, goal_json, plan_json, project_json, "
"agent_json, approval_status, require_approval, auto_dispatch, auto_replan, replan_delay_sec, "
"scheduled_actions_json, dispatch_sessions_json, last_error, created_at, updated_at "
"from planning_sessions where planning_session_id = ? and user_id = ? limit 1")
planning-session-id
user-id)
rows (common/get-sql-rows result)]
(row->planning-session (first rows)))
(p/resolved nil))
(p/resolved nil))
(p/resolved nil)))
(defn <update-planning-session!
[^js env user-id planning-session-id updates]
(p/let [existing (<get-planning-session! env user-id planning-session-id)]
(if-not (map? existing)
nil
(<upsert-planning-session! env (merge existing updates)))))
(defn <update-planning-session-by-id!
[^js env planning-session-id updates]
(p/let [existing (<get-planning-session-by-id! env planning-session-id)]
(if-not (map? existing)
nil
(<upsert-planning-session! env (merge existing updates)))))
(defn <set-planning-approval!
[^js env user-id planning-session-id approval-status]
(<update-planning-session! env
user-id
planning-session-id
{:approval-status (or (some-> approval-status non-empty-str)
"pending")}))

View File

@@ -1,931 +0,0 @@
(ns logseq.agents.planning-workflow
(:require [clojure.string :as string]
[lambdaisland.glogi :as log]
[logseq.agents.planning-store :as planning-store]
[logseq.sync.platform.core :as platform]))
(defn- non-empty-str
[value]
(when (string? value)
(let [trimmed (string/trim value)]
(when-not (string/blank? trimmed)
trimmed))))
(defn- task-content
[task]
(or (some-> (:content task) non-empty-str)
(let [title (some-> (:title task) non-empty-str)
description (some-> (:description task) non-empty-str)]
(cond
(and title description) (str title "\n" description)
title title
description description
:else nil))))
(defn- maybe-task-uuid
[idx task]
(or (some-> (:task-uuid task) non-empty-str)
(some-> (:block-uuid task) non-empty-str)
(str "task-" (inc idx))))
(declare native-promise)
(defn normalize-task
[idx task]
(when (map? task)
(when-let [content (task-content task)]
(cond-> {:title (or (some-> (:title task) non-empty-str)
(str "Task " (inc idx)))
:content content}
(some-> (:description task) non-empty-str) (assoc :description (non-empty-str (:description task)))
(sequential? (:dependencies task)) (assoc :dependencies (vec (:dependencies task)))
(sequential? (:acceptance-criteria task)) (assoc :acceptance-criteria (vec (:acceptance-criteria task)))))))
(defn- normalize-planner-task
[idx task]
(when-let [normalized (normalize-task idx task)]
(cond-> (assoc normalized
:task-uuid (maybe-task-uuid idx task)
:status "Todo"
:marker "#Task")
(some-> (:block-uuid task) non-empty-str) (assoc :block-uuid (non-empty-str (:block-uuid task))))))
(defn normalize-tasks
[tasks]
(->> tasks
(map-indexed normalize-task)
(remove nil?)
vec))
(defn- normalize-planner-tasks
[tasks]
(->> tasks
(map-indexed normalize-planner-task)
(remove nil?)
vec))
(defn- fallback-task-title
[goal fallback]
(or (some-> goal :title non-empty-str)
(some-> goal :node-title non-empty-str)
fallback))
(defn- planning-message-text
[message]
(cond
(string? message)
(non-empty-str message)
(map? message)
(or (some-> (:content message) non-empty-str)
(some-> (:message message) non-empty-str)
(some-> (:text message) non-empty-str))
:else
nil))
(defn- planning-message-texts
[params]
(->> (:planning-messages params)
(keep planning-message-text)
vec))
(defn- repo-note
[params]
(let [repo-url (some-> (get-in params [:project :repo-url]) non-empty-str)
base-branch (some-> (get-in params [:project :base-branch]) non-empty-str)]
(when (string? repo-url)
(str "Repository: " repo-url
(when (string? base-branch)
(str " (base branch: " base-branch ")"))))))
(defn- planning-context-texts
[params]
(let [goal (:goal params)]
(->> [(some-> goal :title non-empty-str)
(some-> goal :description non-empty-str)
(some-> goal :node-title non-empty-str)
(some-> (:replan-note params) non-empty-str)
(some-> (get-in params [:approval :comment]) non-empty-str)
(repo-note params)]
(concat (planning-message-texts params))
(keep non-empty-str)
vec)))
(defn- parse-json-safe
[value]
(if (string? value)
(try
(js->clj (js/JSON.parse value) :keywordize-keys true)
(catch :default _
nil))
nil))
(defn- extract-file-references
[params]
(let [seen (volatile! #{})]
(->> (planning-context-texts params)
(mapcat #(re-seq #"[A-Za-z0-9_./-]+\.(?:clj|cljs|cljc|edn|sql|ts|tsx|js|jsx)" %))
(keep (fn [file-path]
(let [normalized (non-empty-str file-path)]
(when (and (string? normalized)
(not (contains? @seen normalized)))
(vswap! seen conj normalized)
normalized))))
vec)))
(defn- planner-api-token
[^js env params]
(or (some-> (get-in params [:agent :api-token]) non-empty-str)
(some-> (aget env "OPENAI_API_KEY") non-empty-str)))
(defn- planner-base-url
[^js env params]
(let [base-url (or (some-> (get-in params [:agent :base-url]) non-empty-str)
(some-> (aget env "OPENAI_BASE_URL") non-empty-str)
"https://api.openai.com/v1")]
(string/replace base-url #"/+$" "")))
(defn- planner-model
[params]
(or (some-> (get-in params [:agent :model]) non-empty-str)
"gpt-4.1-mini"))
(defn- generic-clarifications
[params]
(let [goal (:goal params)
goal-description (or (some-> goal :description non-empty-str)
(some-> goal :title non-empty-str)
(some-> goal :node-title non-empty-str))
repo-url (some-> (get-in params [:project :repo-url]) non-empty-str)
replan-note (some-> (:replan-note params) non-empty-str)]
(vec
(keep identity
[(when-not (string? repo-url)
"Confirm the target repository and branch before final implementation work begins.")
(when (and (string? goal-description)
(< (count goal-description) 48))
"Clarify the expected user-visible outcome so the plan can stay focused.")
(when (string? replan-note)
(str "Address the replanning feedback: " replan-note))]))))
(defn- generic-workstreams
[params tasks]
(let [file-references (extract-file-references params)]
(cond
(seq tasks)
(->> tasks
(take 4)
(mapv (fn [task]
{:title (or (some-> (:title task) non-empty-str)
"Implementation task")
:description (or (some-> (:description task) non-empty-str)
(task-content task)
"Complete the planned workstream.")})))
(seq file-references)
[{:title "Codebase review"
:description (str "Review the referenced files and current implementation constraints: "
(string/join ", " file-references)
".")}
{:title "Implementation"
:description "Translate the requested change into concrete code and data-flow updates."}
{:title "Validation"
:description "Check the result end-to-end and summarize any follow-up work."}]
:else
[{:title "Investigation"
:description "Review the current state, surrounding constraints, and open questions."}
{:title "Implementation"
:description "Translate the request into concrete implementation tasks."}
{:title "Validation"
:description "Verify the outcome and capture remaining risks."}])))
(defn- generic-milestones
[workstreams]
(mapv (fn [workstream]
{:title (str (:title workstream) " completed")
:description (:description workstream)})
workstreams))
(defn- generic-dependencies
[params]
(vec
(keep identity
[(when-let [repo (repo-note params)]
(str "Use " repo " as the source of truth for planning and validation."))
(when (true? (:require-approval params))
"Execution should remain blocked until the approval state is explicitly updated.")])))
(defn- generic-risks
[params]
(vec
(keep identity
[(when (true? (:require-approval params))
"Approval and execution state can drift if the planning UI is not refreshed after control actions.")
(when-not (string? (some-> (get-in params [:project :repo-url]) non-empty-str))
"Repository-aware planning will stay shallow until repo metadata is available.")])))
(defn- generic-planner-tasks
[params]
(let [goal (:goal params)
goal-description (or (some-> goal :description non-empty-str)
(some-> goal :title non-empty-str)
(some-> goal :node-title non-empty-str)
"Complete the requested work.")
file-references (extract-file-references params)]
[{:title "Inspect current state"
:description (str "Review the goal, current codebase, and constraints. "
goal-description
(when-let [repo (repo-note params)]
(str "\n" repo))
(when (seq file-references)
(str "\nReferenced files: " (string/join ", " file-references))))}
{:title (str "Implement " (fallback-task-title goal "the requested change"))
:description goal-description}
{:title "Validate and summarize"
:description "Check the result, note risks, and summarize follow-up work."}]))
(def ^:private planner-json-schema
{:type "object"
:additionalProperties false
:required ["goal_understanding" "clarifications" "workstreams" "milestones" "tasks" "dependencies" "risks"]
:properties {"goal_understanding" {:type "string"}
"clarifications" {:type "array"
:items {:type "string"}}
"workstreams" {:type "array"
:items {:type "object"
:additionalProperties false
:required ["title" "description"]
:properties {"title" {:type "string"}
"description" {:type "string"}}}}
"milestones" {:type "array"
:items {:type "object"
:additionalProperties false
:required ["title" "description"]
:properties {"title" {:type "string"}
"description" {:type "string"}}}}
"tasks" {:type "array"
:items {:type "object"
:additionalProperties false
:required ["title" "description"]
:properties {"title" {:type "string"}
"description" {:type "string"}
"content" {:type "string"}
"task_uuid" {:type "string"}
"block_uuid" {:type "string"}
"dependencies" {:type "array"
:items {:type "string"}}
"acceptance_criteria" {:type "array"
:items {:type "string"}}}}}
"dependencies" {:type "array"
:items {:type "string"}}
"risks" {:type "array"
:items {:type "string"}}}})
(defn- planner-prompt
[params]
(let [goal (:goal params)
existing-tasks (or (:tasks params)
(:planned-tasks params)
(:existing-tasks params)
[])
context {:goal {:title (some-> goal :title non-empty-str)
:description (or (some-> goal :description non-empty-str)
(some-> goal :node-title non-empty-str))
:node-title (some-> goal :node-title non-empty-str)}
:project {:repo-url (some-> (get-in params [:project :repo-url]) non-empty-str)
:base-branch (some-> (get-in params [:project :base-branch]) non-empty-str)}
:planning-messages (planning-message-texts params)
:existing-tasks (->> existing-tasks
(keep #(select-keys % [:task-uuid :block-uuid :title :description :content :status :session-id]))
vec)
:require-approval (true? (:require-approval params))
:auto-dispatch (if (boolean? (:auto-dispatch params))
(:auto-dispatch params)
true)
:replan-note (some-> (:replan-note params) non-empty-str)}]
(str "You are a software planning model. Produce a concise implementation plan as strict JSON.\n"
"Do not anchor on technology or file names unless they are present in the provided context.\n"
"Use repo metadata and planning messages only as supporting context.\n"
"Return implementation-oriented workstreams, milestones, tasks, dependencies, and risks.\n\n"
"Planning context JSON:\n"
(js/JSON.stringify (clj->js context) nil 2))))
(defn- normalize-model-plan
[payload params]
(when (map? payload)
(let [tasks (normalize-tasks (:tasks payload))
workstreams (if (sequential? (:workstreams payload))
(->> (:workstreams payload)
(keep #(when (map? %)
(select-keys % [:title :description])))
vec)
[])
milestones (if (sequential? (:milestones payload))
(->> (:milestones payload)
(keep #(when (map? %)
(select-keys % [:title :description])))
vec)
[])]
(when (seq tasks)
{:goal-understanding (or (some-> (:goal_understanding payload) non-empty-str)
(some-> (:goal-understanding payload) non-empty-str)
(some-> (:goal params) :description non-empty-str)
(some-> (:goal params) :title non-empty-str)
"")
:clarifications (if (sequential? (:clarifications payload))
(vec (keep non-empty-str (:clarifications payload)))
[])
:workstreams workstreams
:milestones milestones
:tasks tasks
:dependencies (if (sequential? (:dependencies payload))
(vec (keep non-empty-str (:dependencies payload)))
[])
:risks (if (sequential? (:risks payload))
(vec (keep non-empty-str (:risks payload)))
[])}))))
(defn- <planner-model-plan!
[^js env params]
(if-let [token (planner-api-token env params)]
(let [headers (js/Headers.)
_ (.set headers "content-type" "application/json")
_ (.set headers "authorization" (str "Bearer " token))
request-body {:model (planner-model params)
:messages [{:role "system"
:content "You are a planning model that produces structured implementation plans for software tasks."}
{:role "user"
:content (planner-prompt params)}]
:response_format {:type "json_schema"
:json_schema {:name "planning_plan"
:strict true
:schema planner-json-schema}}}
url (str (planner-base-url env params) "/chat/completions")]
(-> (js/fetch url
#js {:method "POST"
:headers headers
:body (js/JSON.stringify (clj->js request-body))})
(.then (fn [response]
(if-not (.-ok response)
nil
(-> (.json response)
(.then (fn [payload]
(let [payload (js->clj payload :keywordize-keys true)
content (some-> payload :choices first :message :content non-empty-str)
plan-payload (or (parse-json-safe content)
(some-> payload :choices first :message :parsed))]
(normalize-model-plan plan-payload params))))))))
(.catch (fn [error]
(log/warn :agent/planning-model-plan-failed error)
nil))))
(native-promise nil)))
(defn- <with-timeout
[promise timeout-ms timeout-value]
(if-not (and (number? timeout-ms)
(pos? timeout-ms))
promise
(js/Promise.race
#js [promise
(js/Promise.
(fn [resolve _reject]
(js/setTimeout
(fn [] (resolve timeout-value))
timeout-ms)))])))
(defn enrich-params-with-model-plan
([^js env params]
(enrich-params-with-model-plan env params nil))
([^js env params {:keys [timeout-ms]}]
(-> (<planner-model-plan! env params)
(<with-timeout timeout-ms nil)
(.then (fn [plan]
(if (map? plan)
(assoc params
:plan plan
:tasks (:tasks plan))
params))))))
(defn- planner-source-tasks
[params]
(let [tasks (or (:tasks params) (:planned-tasks params) (some-> params :plan :tasks))]
(if (seq tasks)
tasks
(generic-planner-tasks params))))
(defn build-plan
[params]
(let [goal (:goal params)
tasks (normalize-tasks (planner-source-tasks params))
existing-plan (when (map? (:plan params)) (:plan params))
workstreams (or (some-> existing-plan :workstreams seq vec)
(generic-workstreams params tasks))]
{:goal-understanding (or (some-> existing-plan :goal-understanding non-empty-str)
(some-> goal :description non-empty-str)
(some-> goal :title non-empty-str)
(some-> goal :node-title non-empty-str)
"")
:clarifications (or (some-> existing-plan :clarifications seq vec)
(generic-clarifications params))
:workstreams workstreams
:milestones (or (some-> existing-plan :milestones seq vec)
(generic-milestones workstreams))
:tasks tasks
:dependencies (or (some-> existing-plan :dependencies seq vec)
(generic-dependencies params))
:risks (or (some-> existing-plan :risks seq vec)
(generic-risks params))}))
(defn- approval-decision
[params]
(let [decision (some-> (get-in params [:approval :decision])
non-empty-str
string/lower-case)]
(cond
(contains? #{"approved" "pending" "rejected"} decision)
decision
(true? (:require-approval params))
"pending"
:else
"approved")))
(defn- started-task?
[task]
(or (string? (some-> (:session-id task) non-empty-str))
(contains? #{"Doing" "Done" "In Review" "Canceled"}
(some-> (:status task) non-empty-str))))
(def ^:private execution-owned-fields
#{:session-id
:status
:runtime-provider
:runner-id
:pr-url
:runtime
:sandbox-checkpoint
:checkpoint-metadata})
(defn- preserve-execution-owned-fields
[existing planned]
(merge planned
(select-keys existing execution-owned-fields)
(select-keys existing [:title :content :description :block-uuid])))
(defn- task-match
[existing task]
(let [task-uuid (some-> (:task-uuid task) non-empty-str)]
(some (fn [candidate]
(when (= task-uuid (some-> (:task-uuid candidate) non-empty-str))
candidate))
existing)))
(defn- reconcile-planned-tasks
[existing-tasks planned-tasks]
(let [existing (vec (filter map? existing-tasks))
matched-task-uuids (volatile! #{})
reconciled (mapv (fn [task]
(if-let [matched (task-match existing task)]
(do
(when-let [task-uuid (some-> (:task-uuid matched) non-empty-str)]
(vswap! matched-task-uuids conj task-uuid))
(if (started-task? matched)
(preserve-execution-owned-fields matched task)
(merge matched task)))
task))
planned-tasks)
untouched-existing (->> existing
(remove (fn [task]
(contains? @matched-task-uuids
(or (some-> (:task-uuid task) non-empty-str)
""))))
(remove nil?)
vec)]
(vec (concat reconciled untouched-existing))))
(defn- planning-status
[approval-status dispatch-sessions auto-dispatch?]
(cond
(= "pending" approval-status) "waiting-approval"
(= "rejected" approval-status) "rejected"
(and auto-dispatch? (seq dispatch-sessions)) "dispatching"
:else "planned"))
(defn- dispatch-session-id
[planning-session-id task]
(or (some-> (:session-id task) non-empty-str)
(some-> (:goal task) :node-id non-empty-str)
(some-> (:goal task) :block-uuid non-empty-str)
(some-> (:goal task) :id non-empty-str)
(some-> task :parent-session-id non-empty-str)
(some-> planning-session-id non-empty-str)))
(defn- dispatch-session
[planning-session-id params task]
(let [runtime-provider (some-> (:runtime-provider params) non-empty-str string/lower-case)
runner-id (some-> (:runner-id params) non-empty-str)
parent-session-id (or (some-> (:execution-session-id params) non-empty-str)
(some-> (:goal params) :node-id non-empty-str)
(some-> (:goal params) :block-uuid non-empty-str))
goal-title (or (some-> (:goal params) :title non-empty-str)
(some-> (:goal params) :node-title non-empty-str)
"Planning Task")]
(cond-> {:id (dispatch-session-id planning-session-id
(assoc task :parent-session-id parent-session-id))
:source {:node-id planning-session-id
:node-title goal-title}
:intent {:content (:content task)}
:project (:project params)
:agent (:agent params)
:task-uuid (:task-uuid task)
:capabilities {:push-enabled true
:pr-enabled true}}
(string? runtime-provider) (assoc :runtime-provider runtime-provider)
(string? runner-id) (assoc :runner-id runner-id))))
(defn- ready-dispatch-task?
[task]
(not (started-task? task)))
(defn- next-dispatchable-tasks
[planning-session-id params tasks]
(if-let [task (some ready-dispatch-task? tasks)]
[(dispatch-session planning-session-id params task)]
[]))
(defn- repo-aware-state
[params]
{:enabled (boolean (some-> (get-in params [:project :repo-url]) non-empty-str))
:repo-url (some-> (get-in params [:project :repo-url]) non-empty-str)
:base-branch (some-> (get-in params [:project :base-branch]) non-empty-str)
:agent-provider (or (some-> (:agent params) :provider non-empty-str)
(some-> (:agent params) non-empty-str))})
(defn- scheduled-actions
[dispatch-ready? dispatch-sessions auto-replan? replan-delay-sec]
(if (and dispatch-ready?
(seq dispatch-sessions)
auto-replan?
(pos-int? replan-delay-sec))
[{:type "replan"
:delay-sec replan-delay-sec}]
[]))
(defn- session-stub
[^js env session-id]
(when-let [^js namespace (aget env "LOGSEQ_AGENT_SESSION_DO")]
(let [do-id (.idFromName namespace session-id)]
(.get namespace do-id))))
(defn- <dispatch-session!
[^js env user-id session-task]
(if-let [^js stub (session-stub env (:id session-task))]
(let [headers (js/Headers.)]
(.set headers "content-type" "application/json")
(when (string? user-id)
(.set headers "x-user-id" user-id))
(let [request (platform/request "https://planning.internal/__session__/init"
#js {:method "POST"
:headers headers
:body (js/JSON.stringify (clj->js session-task))})]
(-> (.fetch stub request)
(.then (fn [response]
{:session-id (:id session-task)
:ok (.-ok response)
:status (.-status response)}))
(.catch (fn [error]
{:session-id (:id session-task)
:ok false
:status 500
:error (str error)})))))
(native-promise {:session-id (:id session-task)
:ok false
:status 503
:error "session durable object unavailable"})))
(defn- <dispatch-sessions!
[^js env user-id dispatch-sessions]
(js/Promise.all
(clj->js
(mapv (fn [session-task]
(<dispatch-session! env user-id session-task))
dispatch-sessions))))
(defn- orchestrate
[params]
(let [planning-session-id (or (some-> (:planning-session-id params) non-empty-str)
(str (random-uuid)))
planned-tasks (normalize-planner-tasks (planner-source-tasks params))
existing-tasks (if (sequential? (:existing-tasks params))
(vec (:existing-tasks params))
[])
reconciled-tasks (reconcile-planned-tasks existing-tasks planned-tasks)
approval-status (approval-decision params)
auto-dispatch? (if (boolean? (:auto-dispatch params))
(:auto-dispatch params)
true)
auto-replan? (true? (:auto-replan params))
replan-delay-sec (let [delay (if (number? (:replan-delay-sec params))
(:replan-delay-sec params)
(some-> (:replan-delay-sec params) str js/parseInt))]
(if (and (number? delay) (not (js/isNaN delay)))
(max 0 delay)
0))
dispatch-ready? (and auto-dispatch?
(= "approved" approval-status))
dispatch-sessions (if dispatch-ready?
(next-dispatchable-tasks planning-session-id params reconciled-tasks)
[])
status (planning-status approval-status dispatch-sessions auto-dispatch?)
plan (build-plan params)]
{:ok true
:status status
:planning-session-id planning-session-id
:plan plan
:reconciled-tasks reconciled-tasks
:dispatch-sessions dispatch-sessions
:scheduled-actions (scheduled-actions dispatch-ready? dispatch-sessions auto-replan? replan-delay-sec)
:planning-state {:planning-session-id planning-session-id
:phase (if (= "waiting-approval" status) "approval" "planning")
:approval-status approval-status
:requires-approval (true? (:require-approval params))
:auto-dispatch auto-dispatch?
:auto-replan auto-replan?
:replan-delay-sec replan-delay-sec
:repo-aware (repo-aware-state params)}
:goal (:goal params)
:require-approval (true? (:require-approval params))}))
(defn workflow-response
[instance details]
{:workflow-id (or (some-> instance .-id)
(:id details))
:status (or (:status details)
"queued")
:details details})
(defn orchestrate-response
[params]
(orchestrate params))
(defn- native-promise
[value]
(js/Promise.resolve value))
(defn- <run-step-do
[step step-name f]
(let [step-do (when step (aget step "do"))]
(if (fn? step-do)
(.call step-do
step
step-name
(fn []
(native-promise (f))))
(native-promise (f)))))
(defn- <dispatch-sessions-with-step!
[^js env user-id dispatch-sessions step step-name]
(if (and (seq dispatch-sessions)
(string? user-id)
env)
(<run-step-do step
step-name
(fn []
(<dispatch-sessions! env user-id dispatch-sessions)))
(native-promise nil)))
(defn- with-dispatched-task-sessions
[orchestrated]
(let [dispatch-sessions (:dispatch-sessions orchestrated)
session-id-by-task (->> dispatch-sessions
(reduce (fn [acc session]
(if-let [task-uuid (some-> (:task-uuid session) non-empty-str)]
(assoc acc task-uuid (:id session))
acc))
{}))]
(if (empty? session-id-by-task)
orchestrated
(update orchestrated
:reconciled-tasks
(fn [tasks]
(mapv (fn [task]
(if-let [session-id (get session-id-by-task
(some-> (:task-uuid task) non-empty-str))]
(cond-> (assoc task :session-id session-id)
(not (started-task? task)) (assoc :status "Doing"))
task))
(or tasks [])))))))
(defn- workflow-event-payload
[event]
(let [event (if (map? event)
event
(js->clj event :keywordize-keys true))]
(when (map? event)
(if (contains? event :payload)
(:payload event)
event))))
(defn- approval-decision-from-event
[event]
(let [payload (workflow-event-payload event)
decision (some-> (:decision payload) non-empty-str string/lower-case)]
(cond
(contains? #{"approved" "pending" "rejected"} decision)
decision
(true? (:approved payload))
"approved"
(false? (:approved payload))
"rejected"
:else
"pending")))
(defn- <wait-for-approval-event!
[step]
(let [wait-for-event (when step (aget step "waitForEvent"))]
(if (fn? wait-for-event)
(-> (.call wait-for-event
step
"wait-for-approval"
#js {:type "approval"
:timeout "30 days"})
(.then approval-decision-from-event)
(.catch (fn [_]
"pending")))
(js/Promise.resolve "pending"))))
(defn- replan-action
[orchestrated]
(->> (:scheduled-actions orchestrated)
(filter map?)
(some (fn [action]
(when (= "replan" (some-> (:type action) non-empty-str string/lower-case))
action)))))
(defn- <sleep-for-replan!
[step delay-sec]
(let [delay-sec (if (and (number? delay-sec)
(not (js/isNaN delay-sec)))
(max 0 delay-sec)
0)
step-sleep (when step (aget step "sleep"))]
(cond
(and (pos-int? delay-sec)
(fn? step-sleep))
(.call step-sleep
step
(str "scheduled-replan-delay-" delay-sec)
(str delay-sec " seconds"))
(pos-int? delay-sec)
(js/Promise.
(fn [resolve _reject]
(js/setTimeout resolve (* delay-sec 1000))))
:else
(js/Promise.resolve nil))))
(defn- <persist-planning-session!
[^js env params orchestrated]
(let [user-id (some-> (:user-id params) non-empty-str)
planning-session-id (some-> (:planning-session-id orchestrated) non-empty-str)
workflow-id (some-> (:workflow-id params) non-empty-str)
persisted-plan (assoc (:plan orchestrated)
:tasks (:reconciled-tasks orchestrated))]
(if (and env
(planning-store/available? env)
(string? user-id)
(string? planning-session-id))
(native-promise
(planning-store/<upsert-planning-session! env
{:planning-session-id planning-session-id
:user-id user-id
:workflow-id workflow-id
:status (:status orchestrated)
:goal (:goal params)
:project (:project params)
:agent (:agent params)
:plan persisted-plan
:approval-status (get-in orchestrated [:planning-state :approval-status])
:require-approval (true? (:require-approval params))
:auto-dispatch (if (boolean? (:auto-dispatch params))
(:auto-dispatch params)
true)
:auto-replan (true? (:auto-replan params))
:replan-delay-sec (:replan-delay-sec params)
:scheduled-actions (:scheduled-actions orchestrated)
:dispatch-sessions (:dispatch-sessions orchestrated)}))
(native-promise nil))))
(defn- replan-event-params
[event]
(let [payload (workflow-event-payload event)
params (:params payload)]
(when (map? params)
params)))
(defn- <wait-for-replan-event!
[step]
(let [wait-for-event (when step (aget step "waitForEvent"))]
(if (fn? wait-for-event)
(-> (.call wait-for-event
step
"wait-for-replan"
#js {:type "replan"
:timeout "30 days"})
(.then replan-event-params)
(.catch (fn [_]
nil)))
(js/Promise.resolve nil))))
(defn- <dispatch-and-schedule!
[^js env user-id step params orchestrated]
(-> (<dispatch-sessions-with-step! env
user-id
(:dispatch-sessions orchestrated)
step
"execution-dispatch")
(.then (fn [dispatch-results]
(let [orchestrated (cond-> orchestrated
(some? dispatch-results) (assoc :dispatch-results dispatch-results))
orchestrated (with-dispatched-task-sessions orchestrated)]
(-> (<persist-planning-session! env params orchestrated)
(.then (fn [_]
(if-let [action (when step
(replan-action orchestrated))]
(let [delay-sec (:delay-sec action)]
(-> (<sleep-for-replan! step delay-sec)
(.then (fn [_]
(let [next-params (assoc params :existing-tasks (:reconciled-tasks orchestrated))
replan-orchestrated (orchestrate next-params)]
(-> (<dispatch-and-schedule! env
user-id
step
next-params
replan-orchestrated)
(.then (fn [result]
(assoc result
:scheduler {:executed true
:action action
:base-status (:status orchestrated)})))))))))
(-> (<wait-for-replan-event! step)
(.then (fn [replan-params]
(if-not (map? replan-params)
orchestrated
(let [next-params (merge params
replan-params
{:existing-tasks (:reconciled-tasks orchestrated)})
replan-orchestrated (orchestrate next-params)]
(<dispatch-and-schedule! env
user-id
step
next-params
replan-orchestrated)))))))))))))))
(defn run
[this event step]
(let [params (if (map? (:params event))
(merge (:params event)
(dissoc event :params))
event)
env (some-> this .-env)
user-id (some-> (:user-id params) non-empty-str)
orchestrated (orchestrate params)]
(-> (if (= "waiting-approval" (:status orchestrated))
(-> (<persist-planning-session! env params orchestrated)
(.then (fn [_]
(<wait-for-approval-event! step)))
(.then (fn [approval-status]
(let [approval-status (if (contains? #{"approved" "pending" "rejected"} approval-status)
approval-status
"pending")
approved-params (assoc params :approval {:decision approval-status})
next-orchestrated (orchestrate approved-params)]
(if (= "approved" approval-status)
(<dispatch-and-schedule! env user-id step approved-params next-orchestrated)
(-> (<persist-planning-session! env approved-params next-orchestrated)
(.then (fn [_]
next-orchestrated))))))))
(<dispatch-and-schedule! env user-id step params orchestrated))
(.catch (fn [error]
(log/error :agent/planning-workflow-run-failed error)
(-> (<persist-planning-session! env
params
{:status "failed"
:planning-session-id (or (:planning-session-id orchestrated)
(:planning-session-id params))
:plan (:plan orchestrated)
:reconciled-tasks (:reconciled-tasks orchestrated)
:dispatch-sessions []
:scheduled-actions []
:planning-state {:approval-status (get-in orchestrated [:planning-state :approval-status])}})
(.then (fn [_]
{:ok false
:status "failed"
:error (str error)}))))))))

View File

@@ -22,29 +22,6 @@
parsed
default-value)))
(defn- non-negative-int
[value default-value]
(max 0 (normalize-int value default-value)))
(defn- normalize-bool
[value default-value]
(if (boolean? value)
value
default-value))
(defn- normalize-approval
[value]
(when (map? value)
(let [decision (some-> (:decision value)
non-empty-str
string/lower-case)
decision (if (contains? #{"pending" "approved" "rejected"} decision)
decision
"pending")
approval-comment (some-> (:comment value) non-empty-str)]
(cond-> {:decision decision}
(string? approval-comment) (assoc :comment approval-comment)))))
(defn normalize-session-create
[body]
(when (map? body)
@@ -71,67 +48,6 @@
(string? runtime-provider) (assoc :runtime-provider runtime-provider)
(string? runner-id) (assoc :runner-id runner-id)))))
(defn normalize-planning-create
[body]
(when-let [task (normalize-session-create body)]
(let [agent (:agent task)]
(assoc task
:agent
(cond
(string? agent)
{:provider agent
:permission-mode "read-only"}
(map? agent)
(cond-> agent
(and (= "codex" (some-> (:provider agent) non-empty-str string/lower-case))
(nil? (:permission-mode agent))
(nil? (:permissionMode agent)))
(assoc :permission-mode "read-only"))
:else agent)))))
(defn normalize-planning-workflow-create
[body]
(when (map? body)
(let [workflow-id (some-> (:workflow-id body) non-empty-str)
planning-session-id (some-> (:planning-session-id body) non-empty-str)
user-id (some-> (:user-id body) non-empty-str)
goal (:goal body)
tasks (or (:tasks body) (:planned-tasks body))
tasks (when (sequential? tasks) (vec tasks))
planning-messages (when (sequential? (:planning-messages body))
(vec (:planning-messages body)))
require-approval? (true? (:require-approval body))
auto-dispatch? (normalize-bool (:auto-dispatch body) true)
auto-replan? (normalize-bool (:auto-replan body) false)
replan-delay-sec (non-negative-int (:replan-delay-sec body) 0)
replan-note (or (some-> (:replan-note body) non-empty-str)
(some-> (:comment body) non-empty-str))
approval (normalize-approval (:approval body))
project (when (map? (:project body)) (:project body))
agent (:agent body)
runtime-provider (some-> (:runtime-provider body)
non-empty-str
string/lower-case)
runner-id (some-> (:runner-id body) non-empty-str)]
(cond-> {:require-approval require-approval?
:auto-dispatch auto-dispatch?
:auto-replan auto-replan?
:replan-delay-sec replan-delay-sec}
(string? workflow-id) (assoc :workflow-id workflow-id)
(string? planning-session-id) (assoc :planning-session-id planning-session-id)
(string? user-id) (assoc :user-id user-id)
(map? goal) (assoc :goal goal)
(sequential? tasks) (assoc :tasks tasks)
(sequential? planning-messages) (assoc :planning-messages planning-messages)
(map? project) (assoc :project project)
(some? agent) (assoc :agent agent)
(string? runtime-provider) (assoc :runtime-provider runtime-provider)
(string? runner-id) (assoc :runner-id runner-id)
(string? replan-note) (assoc :replan-note replan-note)
(map? approval) (assoc :approval approval)))))
(defn normalize-runner-register
[body user-id]
(when (map? body)

View File

@@ -5,16 +5,6 @@
[["/auth"
["/chatgpt/import" {:methods {"POST" :auth.chatgpt/import}}]
["/chatgpt/status" {:methods {"GET" :auth.chatgpt/status}}]]
["/planning"
["/sessions" {:methods {"POST" :planning.sessions/create}}]
["/sessions/:planning-session-id" {:methods {"GET" :planning.sessions/get}}]
["/sessions/:planning-session-id/approval" {:methods {"POST" :planning.sessions/approval}}]
["/sessions/:planning-session-id/tasks/sync" {:methods {"POST" :planning.sessions/tasks.sync}}]
["/sessions/:planning-session-id/replan" {:methods {"POST" :planning.sessions/replan}}]
["/chat/:planning-session-id" {:methods {"GET" :planning.chat/transport
"POST" :planning.chat/transport}}]
["/workflows" {:methods {"POST" :planning.workflows/create}}]
["/workflows/:workflow-id" {:methods {"GET" :planning.workflows/get}}]]
["/sessions"
["" {:methods {"POST" :sessions/create}}]
["/:session-id"

View File

@@ -1,15 +1,11 @@
(ns logseq.agents.worker
;; Turn off false defclass errors
{:clj-kondo/config {:linters {:unresolved-symbol {:level :off}}}}
(:require ["agents" :refer [Agent]]
["cloudflare:workers" :refer [DurableObject WorkflowEntrypoint]]
(:require ["cloudflare:workers" :refer [DurableObject]]
[logseq.agents.dispatch :as dispatch]
[logseq.agents.do :as agent-do]
[logseq.agents.planning-agent :as planning-agent]
[logseq.agents.planning-workflow :as planning-workflow]
[logseq.sync.logging :as logging]
[logseq.sync.sentry.worker :as sentry]
[promesa.core :as p]
[shadow.cljs.modern :refer (defclass)]))
(logging/install!)
@@ -32,106 +28,3 @@
Object
(fetch [this request]
(agent-do/handle-fetch this request)))
(defclass PlanningWorkflow
(extends WorkflowEntrypoint)
(constructor [this ctx env]
(super ctx env)
(set! (.-ctx this) ctx)
(set! (.-env this) env))
Object
(run [this event step]
(planning-workflow/run this event step)))
(defn- planning-session-id
[this props]
(or (some-> props :planning-session-id str)
(some-> this .-name str)
"default"))
(defn- get-planning-state
[this props]
(let [planning-session-id (planning-session-id this props)
current-state (some-> this .-state (js->clj :keywordize-keys true))]
(planning-agent/normalize-state planning-session-id current-state)))
(defn- set-planning-state!
[^js this state]
(let [state' (clj->js state)
set-state (aget this "setState")
set-state-internal (aget this "_setStateInternal")]
(cond
(fn? set-state)
(.call set-state this state')
(fn? set-state-internal)
(.call set-state-internal this state' "server")
:else
(set! (.-_state this) state')))
state)
(defclass PlanningSessionAgent
(extends Agent)
(constructor [this ctx env]
(super ctx env)
(set! (.-ctx this) ctx)
(set! (.-env this) env))
Object
(onStart [this props]
(let [props (when (some? props)
(js->clj props :keywordize-keys true))]
(set-planning-state! this (get-planning-state this props))))
(onRequest [this request]
(let [method (.-method request)]
(cond
(= method "GET")
(planning-agent/json-response (get-planning-state this nil) 200)
(= method "POST")
(-> (.text request)
(p/then (fn [raw]
(if-let [content (planning-agent/parse-message-content raw)]
(let [session-id (planning-session-id this nil)
next-state (-> (get-planning-state this nil)
(planning-agent/append-message session-id "user" content)
(set-planning-state! this))]
(planning-agent/json-response {:ok true
:state next-state}
200))
(planning-agent/json-response {:error "invalid message"} 400))))
(p/catch (fn [_error]
(planning-agent/json-response {:error "invalid message"} 400))))
:else
(planning-agent/json-response {:error "method not allowed"} 405))))
(onConnect [this connection _ctx]
(let [state (get-planning-state this nil)]
(.send connection
(js/JSON.stringify
(clj->js {:type "planning.state"
:state state})))))
(onMessage [this connection message]
(let [content (planning-agent/parse-message-content message)]
(if-not (string? content)
(.send connection
(js/JSON.stringify
#js {:type "planning.error"
:message "invalid message"}))
(let [session-id (planning-session-id this nil)
next-state (-> (get-planning-state this nil)
(planning-agent/append-message session-id "user" content)
(set-planning-state! this))
latest-message (last (:messages next-state))]
(.broadcast this
(js/JSON.stringify
(clj->js {:type "planning.message"
:message latest-message
:state next-state}))))))))

View File

@@ -373,32 +373,6 @@
[:snapshot-id {:optional true} [:maybe :string]]
[:message {:optional true} [:maybe :string]]])
(def planning-session-response-schema
[:map
[:planning-session-id :string]
[:status :string]
[:workflow-id {:optional true} [:maybe :string]]
[:chat-path {:optional true} :string]
[:goal {:optional true} :map]
[:project {:optional true} :map]
[:agent {:optional true} :any]
[:approval-status {:optional true} :string]
[:require-approval {:optional true} :boolean]
[:auto-dispatch {:optional true} :boolean]
[:auto-replan {:optional true} :boolean]
[:replan-delay-sec {:optional true} :int]
[:plan {:optional true} :map]
[:scheduled-actions {:optional true} [:sequential :map]]
[:dispatch-sessions {:optional true} [:sequential :map]]
[:created-at {:optional true} :int]
[:updated-at {:optional true} :int]])
(def planning-workflow-response-schema
[:map
[:workflow-id :string]
[:status :string]
[:planning-session-id {:optional true} :string]])
(def runner-response-schema
[:map
[:runner-id :string]
@@ -477,10 +451,6 @@
:sessions/snapshot sessions-snapshot-response-schema
:sessions/events sessions-events-response-schema
:sessions/branches sessions-branches-response-schema
:planning.sessions/create planning-session-response-schema
:planning.sessions/get planning-session-response-schema
:planning.workflows/create planning-workflow-response-schema
:planning.workflows/get planning-workflow-response-schema
:auth.chatgpt/import auth-chatgpt-status-response-schema
:auth.chatgpt/status auth-chatgpt-status-response-schema
:runners/register runners-register-response-schema

View File

@@ -51,7 +51,6 @@
(index-handler/handle-fetch #js {:env env :d1 (aget env "DB")} request)
(or (string/starts-with? path "/auth")
(string/starts-with? path "/planning")
(string/starts-with? path "/sessions"))
(if-let [^js agents-service (aget env "AGENTS_SERVICE")]
(if (local-dev-host? request)

View File

@@ -1,60 +0,0 @@
(ns logseq.agents.handler-test
(:require [cljs.test :refer [async deftest is]]
[logseq.agents.handler :as handler]
[logseq.sync.platform.core :as platform]))
(deftest planning-session-get-requires-planning-store-test
(async done
(let [request (platform/request "http://example.com/planning/sessions/plan-1"
#js {:method "GET"})]
(-> (js/Promise.resolve
(handler/handle {:env #js {}
:request request
:url (platform/request-url request)
:claims #js {"sub" "user-1"}
:route {:handler :planning.sessions/get
:path-params {:planning-session-id "plan-1"}}}))
(.then (fn [response]
(is (= 503 (.-status response)))
(done)))
(.catch (fn [error]
(is false (str "unexpected error: " error))
(done)))))))
(deftest planning-chat-transport-requires-agent-binding-test
(async done
(let [request (platform/request "http://example.com/planning/chat/plan-1"
#js {:method "GET"})]
(-> (js/Promise.resolve
(handler/handle {:env #js {}
:request request
:url (platform/request-url request)
:claims #js {"sub" "user-1"}
:route {:handler :planning.chat/transport
:path-params {:planning-session-id "plan-1"}}}))
(.then (fn [response]
(is (= 503 (.-status response)))
(done)))
(.catch (fn [error]
(is false (str "unexpected error: " error))
(done)))))))
(deftest planning-session-replan-requires-workflow-binding-test
(async done
(let [request (platform/request "http://example.com/planning/sessions/plan-1/replan"
#js {:method "POST"
:headers #js {"content-type" "application/json"}
:body (js/JSON.stringify #js {:tasks #js []})})]
(-> (js/Promise.resolve
(handler/handle {:env #js {}
:request request
:url (platform/request-url request)
:claims #js {"sub" "user-1"}
:route {:handler :planning.sessions/replan
:path-params {:planning-session-id "plan-1"}}}))
(.then (fn [response]
(is (= 503 (.-status response)))
(done)))
(.catch (fn [error]
(is false (str "unexpected error: " error))
(done)))))))

View File

@@ -1,122 +0,0 @@
(ns logseq.agents.planning-workflow-test
(:require [cljs.test :refer [async deftest is]]
[logseq.agents.planning-workflow :as planning-workflow]))
(deftest build-plan-test
(let [plan (planning-workflow/build-plan
{:goal {:title "Plan Goal"
:description "Build a planner"}
:tasks [{:title "Task A"
:description "Desc A"}
{:content "Explicit content"}]})]
(is (= "Build a planner" (:goal-understanding plan)))
(is (= [{:title "Task A"
:content "Task A\nDesc A"
:description "Desc A"}
{:title "Task 2"
:content "Explicit content"}]
(:tasks plan)))))
(deftest run-awaits-approval-before-dispatch-test
(async done
(-> (planning-workflow/run nil
{:planning-session-id "plan-1"
:goal {:title "Plan Goal"
:description "Build planning workflow"}
:tasks [{:title "Task A"
:description "Desc A"}]
:project {:id "project-1"
:repo-url "https://github.com/example/repo"
:base-branch "main"}
:agent {:provider "codex"}
:runtime-provider "local-runner"
:runner-id "runner-1"
:require-approval true
:approval {:decision "pending"}
:auto-dispatch true
:auto-replan true
:replan-delay-sec 300}
nil)
(.then (fn [result]
(is (= "waiting-approval" (:status result)))
(is (= [] (:dispatch-sessions result)))
(is (= "pending" (get-in result [:planning-state :approval-status])))
(is (= "https://github.com/example/repo"
(get-in result [:planning-state :repo-aware :repo-url])))
(is (= [] (:scheduled-actions result)))
(done)))
(.catch (fn [error]
(is false (str "unexpected error: " error))
(done))))))
(deftest run-dispatches-approved-repo-aware-tasks-and-schedules-replan-test
(async done
(-> (planning-workflow/run nil
{:planning-session-id "plan-2"
:goal {:title "Plan Goal"
:description "Build planning workflow"}
:tasks [{:title "Task A"
:description "Desc A"}]
:project {:id "project-1"
:repo-url "https://github.com/example/repo"
:base-branch "main"}
:agent {:provider "codex"
:mode "gpt-5-codex"}
:runtime-provider "local-runner"
:runner-id "runner-1"
:require-approval true
:approval {:decision "approved"}
:auto-dispatch true
:auto-replan true
:replan-delay-sec 120}
nil)
(.then (fn [result]
(is (= "dispatching" (:status result)))
(is (= 1 (count (:dispatch-sessions result))))
(is (= "https://github.com/example/repo"
(get-in result [:dispatch-sessions 0 :project :repo-url])))
(is (= "codex"
(get-in result [:dispatch-sessions 0 :agent :provider])))
(is (= "local-runner" (get-in result [:dispatch-sessions 0 :runtime-provider])))
(is (= "runner-1" (get-in result [:dispatch-sessions 0 :runner-id])))
(is (= [{:type "replan"
:delay-sec 120}]
(:scheduled-actions result)))
(done)))
(.catch (fn [error]
(is false (str "unexpected error: " error))
(done))))))
(deftest run-replanning-preserves-execution-owned-fields-test
(async done
(-> (planning-workflow/run nil
{:planning-session-id "plan-3"
:goal {:title "Plan Goal"}
:tasks [{:task-uuid "task-1"
:title "Task A (replanned)"
:description "Desc A"}
{:task-uuid "task-2"
:title "Task B"
:description "Desc B"}]
:existing-tasks [{:task-uuid "task-1"
:title "Task A"
:status "Doing"
:session-id "sess-1"
:pr-url "https://github.com/example/repo/pull/1"}]
:project {:id "project-1"
:repo-url "https://github.com/example/repo"}
:agent {:provider "codex"}
:approval {:decision "approved"}
:auto-dispatch false
:auto-replan false}
nil)
(.then (fn [result]
(is (= "Task A" (get-in result [:reconciled-tasks 0 :title])))
(is (= "sess-1" (get-in result [:reconciled-tasks 0 :session-id])))
(is (= "https://github.com/example/repo/pull/1"
(get-in result [:reconciled-tasks 0 :pr-url])))
(is (= 2 (count (:reconciled-tasks result))))
(done)))
(.catch (fn [error]
(is false (str "unexpected error: " error))
(done))))))

View File

@@ -63,95 +63,6 @@
:pr-enabled true}}
normalized)))))
(deftest normalize-planning-create-test
(testing "normalizes planning request into agent task with codex read-only default"
(let [body {:session-id "plan-1"
:node-id "goal-1"
:node-title "Plan Goal"
:content "Plan this task graph"
:project {:id "project-1"
:title "Demo Project"
:repo-url "https://github.com/example/repo"}
:agent "codex"}
normalized (request/normalize-planning-create body)]
(is (= {:id "plan-1"
:source {:node-id "goal-1"
:node-title "Plan Goal"}
:intent {:content "Plan this task graph"}
:project {:id "project-1"
:title "Demo Project"
:repo-url "https://github.com/example/repo"}
:agent {:provider "codex"
:permission-mode "read-only"}
:capabilities {:push-enabled true
:pr-enabled true}}
normalized))))
(testing "keeps explicit agent permission mode"
(let [body {:session-id "plan-2"
:node-id "goal-2"
:node-title "Plan Goal"
:content "Plan this task graph"
:project {:id "project-1"
:title "Demo Project"
:repo-url "https://github.com/example/repo"}
:agent {:provider "codex"
:permission-mode "full-access"}}
normalized (request/normalize-planning-create body)]
(is (= "full-access" (get-in normalized [:agent :permission-mode]))))))
(deftest normalize-planning-workflow-create-test
(let [body {:workflow-id "wf-1"
:planning-session-id "plan-1"
:user-id "user-1"
:goal {:title "Plan Goal"
:description "Plan this feature"}
:tasks [{:title "Task A"
:description "Desc A"}]
:project {:id "project-1"
:repo-url "https://github.com/example/repo"
:base-branch "main"}
:agent {:provider "codex"}
:runtime-provider "local-runner"
:runner-id "runner-1"
:approval {:decision "approved"
:comment "looks good"}
:auto-dispatch true
:auto-replan true
:replan-delay-sec 300
:require-approval true}
normalized (request/normalize-planning-workflow-create body)]
(is (= {:workflow-id "wf-1"
:planning-session-id "plan-1"
:user-id "user-1"
:goal {:title "Plan Goal"
:description "Plan this feature"}
:tasks [{:title "Task A"
:description "Desc A"}]
:project {:id "project-1"
:repo-url "https://github.com/example/repo"
:base-branch "main"}
:agent {:provider "codex"}
:runtime-provider "local-runner"
:runner-id "runner-1"
:approval {:decision "approved"
:comment "looks good"}
:auto-dispatch true
:auto-replan true
:replan-delay-sec 300
:require-approval true}
normalized))))
(deftest normalize-planning-workflow-create-defaults-test
(let [body {:goal {:title "Plan Goal"}}
normalized (request/normalize-planning-workflow-create body)]
(is (= {:goal {:title "Plan Goal"}
:require-approval false
:auto-dispatch true
:auto-replan false
:replan-delay-sec 0}
normalized))))
(deftest sessions-pr-coerce-test
(testing "accepts sessions/pr request payload"
(let [body {:title "feat: add m14 publish"

View File

@@ -68,25 +68,3 @@
(is (= :auth.chatgpt/import (:handler match))))
(let [match (routes/match-route "GET" "/auth/chatgpt/status")]
(is (= :auth.chatgpt/status (:handler match))))))
(deftest match-route-planning-test
(testing "planning routes"
(let [match (routes/match-route "POST" "/planning/sessions")]
(is (= :planning.sessions/create (:handler match))))
(let [match (routes/match-route "GET" "/planning/sessions/plan-1")]
(is (= :planning.sessions/get (:handler match)))
(is (= "plan-1" (get-in match [:path-params :planning-session-id]))))
(let [match (routes/match-route "POST" "/planning/sessions/plan-1/approval")]
(is (= :planning.sessions/approval (:handler match)))
(is (= "plan-1" (get-in match [:path-params :planning-session-id]))))
(let [match (routes/match-route "POST" "/planning/sessions/plan-1/replan")]
(is (= :planning.sessions/replan (:handler match)))
(is (= "plan-1" (get-in match [:path-params :planning-session-id]))))
(let [match (routes/match-route "GET" "/planning/chat/plan-1")]
(is (= :planning.chat/transport (:handler match)))
(is (= "plan-1" (get-in match [:path-params :planning-session-id]))))
(let [match (routes/match-route "POST" "/planning/workflows")]
(is (= :planning.workflows/create (:handler match))))
(let [match (routes/match-route "GET" "/planning/workflows/workflow-1")]
(is (= :planning.workflows/get (:handler match)))
(is (= "workflow-1" (get-in match [:path-params :workflow-id]))))))

View File

@@ -1,9 +1,5 @@
(ns logseq.agents.runtime-transport-test-runner
(:require [cljs.test :as ct]
[logseq.agents.handler-test]
[logseq.agents.planning-workflow-test]
[logseq.agents.request-test]
[logseq.agents.routes-test]
[logseq.agents.runtime-provider-test]
[logseq.agents.sandbox-test]
[shadow.test :as st]
@@ -23,17 +19,6 @@
(defn main [& _args]
(reset-test-data!)
(ct/test-vars [#'logseq.agents.runtime-provider-test/local-runner-provider-provision-test
#'logseq.agents.planning-workflow-test/build-plan-test
#'logseq.agents.planning-workflow-test/run-awaits-approval-before-dispatch-test
#'logseq.agents.planning-workflow-test/run-dispatches-approved-repo-aware-tasks-and-schedules-replan-test
#'logseq.agents.planning-workflow-test/run-replanning-preserves-execution-owned-fields-test
#'logseq.agents.routes-test/match-route-planning-test
#'logseq.agents.request-test/normalize-planning-create-test
#'logseq.agents.request-test/normalize-planning-workflow-create-test
#'logseq.agents.request-test/normalize-planning-workflow-create-defaults-test
#'logseq.agents.handler-test/planning-session-get-requires-planning-store-test
#'logseq.agents.handler-test/planning-chat-transport-requires-agent-binding-test
#'logseq.agents.handler-test/planning-session-replan-requires-workflow-binding-test
#'logseq.agents.sandbox-test/session-endpoint-test
#'logseq.agents.sandbox-test/create-session-payload-test
#'logseq.agents.sandbox-test/send-message-uses-acp-prompt-test

View File

@@ -1,11 +1,11 @@
(ns logseq.sync.worker-test
(:require [cljs.test :refer [async deftest is]]
[datascript.core :as d]
[logseq.db.common.order :as db-order]
[logseq.db.frontend.schema :as db-schema]
[logseq.sync.order :as sync-order]
[logseq.sync.platform.core :as platform]
[logseq.sync.worker.dispatch :as dispatch]))
[logseq.sync.worker.dispatch :as dispatch]
[logseq.db.common.order :as db-order]
[logseq.db.frontend.schema :as db-schema]))
(defn- new-conn []
(d/create-conn db-schema/schema))
@@ -55,17 +55,6 @@
(is false (str "unexpected error: " error))
(done)))))))
(deftest dispatch-worker-fetch-planning-service-unavailable-test
(async done
(let [request (platform/request "http://example.com/planning/sessions" #js {:method "POST"})
resp (dispatch/handle-worker-fetch request #js {})]
(-> (.then resp (fn [resolved]
(is (= 503 (.-status resolved)))
(done)))
(.catch (fn [error]
(is false (str "unexpected error: " error))
(done)))))))
(deftest dispatch-worker-fetch-sessions-forward-to-agents-service-test
(async done
(let [request (platform/request "http://example.com/sessions/session-2/events?since=1" #js {:method "GET"})
@@ -85,27 +74,6 @@
(is false (str "unexpected error: " error))
(done)))))))
(deftest dispatch-worker-fetch-planning-forward-to-agents-service-test
(async done
(let [request (platform/request "http://example.com/planning/sessions" #js {:method "POST"
:headers #js {"content-type" "application/json"}
:body (js/JSON.stringify #js {:session-id "plan-1"})})
captured-url (atom nil)
env #js {:AGENTS_SERVICE #js {:fetch (fn [forwarded]
(reset! captured-url (.-url forwarded))
(js/Promise.resolve
(js/Response. (js/JSON.stringify #js {:ok true})
#js {:status 202
:headers #js {"content-type" "application/json"}})))}}]
(-> (dispatch/handle-worker-fetch request env)
(.then (fn [resolved]
(is (= 202 (.-status resolved)))
(is (= "http://example.com/planning/sessions" @captured-url))
(done)))
(.catch (fn [error]
(is false (str "unexpected error: " error))
(done)))))))
(deftest dispatch-worker-fetch-sessions-local-retry-test
(async done
(let [request (platform/request "http://127.0.0.1:8787/sessions/session-3"

View File

@@ -1,26 +0,0 @@
create table if not exists planning_sessions (
planning_session_id text primary key,
user_id text not null,
workflow_id text,
status text not null default 'queued',
goal_json text,
plan_json text,
project_json text,
agent_json text,
approval_status text not null default 'pending',
require_approval integer not null default 0,
auto_dispatch integer not null default 1,
auto_replan integer not null default 0,
replan_delay_sec integer not null default 0,
scheduled_actions_json text,
dispatch_sessions_json text,
last_error text,
created_at integer not null,
updated_at integer not null
);
create index if not exists idx_planning_sessions_user_updated_at
on planning_sessions (user_id, updated_at desc);
create index if not exists idx_planning_sessions_workflow_id
on planning_sessions (workflow_id);

View File

@@ -16,15 +16,6 @@ invocation_logs = false
name = "LOGSEQ_AGENT_SESSION_DO"
class_name = "AgentSessionDO"
[[durable_objects.bindings]]
name = "PLANNING_AGENT"
class_name = "PlanningSessionAgent"
[[workflows]]
binding = "PLANNING_WORKFLOW"
name = "logseq-agents-planning-dev"
class_name = "PlanningWorkflow"
[[r2_buckets]]
binding = "BACKUP_BUCKET"
bucket_name = "logseq-sync-assets-dev"
@@ -39,10 +30,6 @@ database_id = "00325aa2-c805-4693-b599-900a25dcde42"
tag = "v1"
new_sqlite_classes = [ "AgentSessionDO" ]
[[migrations]]
tag = "v2"
new_sqlite_classes = [ "PlanningSessionAgent" ]
[vars]
COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8/.well-known/jwks.json"
COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8"
@@ -69,15 +56,6 @@ SENTRY_TRACES_SAMPLE_RATE = "0.1"
name = "LOGSEQ_AGENT_SESSION_DO"
class_name = "AgentSessionDO"
[[env.staging.durable_objects.bindings]]
name = "PLANNING_AGENT"
class_name = "PlanningSessionAgent"
[[env.staging.workflows]]
binding = "PLANNING_WORKFLOW"
name = "logseq-agents-planning-staging"
class_name = "PlanningWorkflow"
[[env.staging.r2_buckets]]
binding = "BACKUP_BUCKET"
bucket_name = "logseq-sync-assets-dev"
@@ -92,10 +70,6 @@ database_id = "00325aa2-c805-4693-b599-900a25dcde42"
tag = "v1"
new_sqlite_classes = [ "AgentSessionDO" ]
[[env.staging.migrations]]
tag = "v2"
new_sqlite_classes = [ "PlanningSessionAgent" ]
[env.staging.version_metadata]
binding = "CF_VERSION_METADATA"
@@ -117,15 +91,6 @@ SENTRY_TRACES_SAMPLE_RATE = "0.1"
name = "LOGSEQ_AGENT_SESSION_DO"
class_name = "AgentSessionDO"
[[env.prod.durable_objects.bindings]]
name = "PLANNING_AGENT"
class_name = "PlanningSessionAgent"
[[env.prod.workflows]]
binding = "PLANNING_WORKFLOW"
name = "logseq-agents-planning-prod"
class_name = "PlanningWorkflow"
[[env.prod.r2_buckets]]
binding = "BACKUP_BUCKET"
bucket_name = "logseq-sync-assets-prod"
@@ -140,9 +105,5 @@ database_id = "4c80e058-69b5-4985-88d1-f53711d817ba"
tag = "v1"
new_sqlite_classes = [ "AgentSessionDO" ]
[[env.prod.migrations]]
tag = "v2"
new_sqlite_classes = [ "PlanningSessionAgent" ]
[env.prod.version_metadata]
binding = "CF_VERSION_METADATA"

862
deps/workers/yarn.lock vendored

File diff suppressed because it is too large Load Diff

View File

@@ -149,57 +149,6 @@ Errors and idempotency:
- GET /sandbox/sessions/:id/stream
- server-sent events of normalized agent events
## Planning Layer
- Planning sessions are separate from execution sessions.
- Planning sessions are backed by a Cloudflare Agent instance and addressed by
`planning-session-id`.
- Planning workflows are backed by Cloudflare Workflows and persist their
orchestration state into `planning_sessions`.
- Planning state is product-visible through:
- `GET /planning/sessions/:planning-session-id`
- `POST /planning/sessions`
- `POST /planning/sessions/:planning-session-id/approval`
- `POST /planning/sessions/:planning-session-id/replan`
- `GET|POST /planning/chat/:planning-session-id`
### Planning Session Shape
- `planning-session-id`
- `workflow-id`
- `status`
- `goal`
- `project`
- `agent`
- `plan`
- `approval-status`
- `require-approval`
- `auto-dispatch`
- `auto-replan`
- `replan-delay-sec`
- `scheduled-actions`
- `dispatch-sessions`
### Planning To Execution Mapping
- A planning task becomes a normal execution `POST /sessions` payload.
- The mapping is:
- `dispatch-session.id` -> `sessions/create.session-id`
- `dispatch-session.source.node-id` -> `source.node-id`
- `dispatch-session.source.node-title` -> `source.node-title`
- `dispatch-session.intent.content` -> `intent.content`
- `dispatch-session.project` -> `project`
- `dispatch-session.agent` -> `agent`
- `dispatch-session.runtime-provider` -> `runtime-provider`
- `dispatch-session.runner-id` -> `runner-id`
- `dispatch-session.capabilities` -> `capabilities`
- Execution remains on the existing `/sessions` substrate; planning never
provisions a second execution backend.
### Planning Ownership Rules
- Planning session reads/writes are always resolved against the authenticated
`claims.sub`.
- Planning chat transport is not forwarded by `planning-session-id` alone.
- Approval and replan act on the stored planning session and its tracked
workflow, not on arbitrary workflow ids supplied by the client.
## Example: Session Creation Payload
```json
{

View File

@@ -154,8 +154,8 @@
"@tabler/icons-react": "^2.47.0",
"@tabler/icons-webfont": "^2.47.0",
"@tippyjs/react": "4.2.5",
"agents": "^0.7.5",
"aws-amplify": "^6.15.6",
"ghostty-web": "^0.4.0",
"bignumber.js": "^9.0.2",
"chokidar": "3.5.1",
"chrono-node": "2.2.4",
@@ -167,7 +167,6 @@
"fs": "0.0.1-security",
"fs-extra": "9.1.0",
"fuse.js": "6.4.6",
"ghostty-web": "^0.4.0",
"grapheme-splitter": "1.0.4",
"graphology": "0.20.0",
"hnswlib-wasm": "^0.8.2",

View File

@@ -1,15 +1,12 @@
(ns frontend.components.agent-chat
(:require ["@ai-sdk/react" :refer [useChat]]
["agents/client" :refer [AgentClient]]
["ghostty-web" :refer [FitAddon Terminal init]]
[cljs-bean.core :as bean]
[clojure.set :as set]
[clojure.string :as string]
[frontend.components.select :as select]
[frontend.handler.agent :as agent-handler]
[frontend.handler.agent-chat-transport :as chat-transport]
[frontend.handler.db-based.sync :as db-sync]
[frontend.handler.notification :as notification]
[frontend.modules.agent-chat.event :as chat-event]
[frontend.state :as state]
[logseq.shui.hooks :as hooks]
@@ -102,9 +99,7 @@
(defn- ^:large-vars/cleanup-todo session->messages
[session block]
(let [planning-messages (when (sequential? (:planning-messages session))
(vec (:planning-messages session)))
events (:events session)
(let [events (:events session)
base (let [acc (atom {:items {}
:order []
:item-kind-by-id {}
@@ -371,18 +366,16 @@
(append-text-part! item-id role delta))))))))]
(doseq [event events]
(process-event! event))
(if (seq planning-messages)
planning-messages
(->> (:order @acc)
(keep (fn [item-id]
(let [entry (get-in @acc [:items item-id])
parts (vec (keep normalize-message-part (:parts entry)))
role (chat-role (:role entry))]
(when (seq parts)
{:id item-id
:role role
:parts parts}))))
vec))))
(->> (:order @acc)
(keep (fn [item-id]
(let [entry (get-in @acc [:items item-id])
parts (vec (keep normalize-message-part (:parts entry)))
role (chat-role (:role entry))]
(when (seq parts)
{:id item-id
:role role
:parts parts}))))
vec)))
task-text (some-> (or (:block/raw-title block) (:block/title block))
string/trim)
user-message (when-not (string/blank? task-text)
@@ -434,48 +427,6 @@
(let [single-line (string/replace summary #"\s+" " ")]
(subs single-line 0 (min commit-message-max-len (count single-line))))))
(def ^:private planner-started-statuses
#{"Doing" "Done" "In Review" "Canceled"})
(defn- planning-session-tasks
[session session-chat-messages]
(or (some-> session :plan :tasks seq vec)
(some-> session-chat-messages
latest-assistant-summary
agent-handler/planner-tasks-from-text)))
(defn- planning-task-started?
[task]
(or (string? (normalized-text (:session-id task)))
(contains? planner-started-statuses
(some-> (:status task) normalized-text))))
(defn- executable-planner-tasks
[tasks]
(->> tasks
(filter map?)
(remove planning-task-started?)
vec))
(defn- planning-task-title
[task]
(or (some-> (:title task) normalized-text)
(some-> (:content task)
normalized-text
(string/split #"\n")
first
normalized-text)
"Planned task"))
(defn- planning-task-status-label
[task]
(or (some-> (:status task) normalized-text)
(when (string? (normalized-text (:session-id task)))
"Doing")
(when (string? (normalized-text (:block-uuid task)))
"Todo")
"Draft"))
(defn- session-messages-need-sync?
[session-messages ui-messages]
(let [ui-by-id (into {} (map (juxt :id identity) ui-messages))]
@@ -618,9 +569,7 @@
[block]
(let [block-uuid (:block/uuid block)
[sessions] (hooks/use-atom (:agent/sessions @state/state))
[planning-sessions] (hooks/use-atom (:agent/planning-sessions @state/state))
session (or (get planning-sessions (str block-uuid))
(get sessions (str block-uuid)))
session (get sessions (str block-uuid))
session-id (or (:session-id session)
(agent-handler/task-session-id block)
(some-> block-uuid str))
@@ -630,25 +579,12 @@
pr-created? (string? task-pr-url)
agent-value (:logseq.property/agent block)
agent-label (agent-title agent-value)
session-kind (some-> (:session-kind session) normalized-text)
planning-chat-path (some-> (:planning-chat-path session) normalized-text)
planning-session? (or (= "planning" session-kind)
(string? planning-chat-path))
planning-session-id (some-> (:planning-session-id session) normalized-text)
execution-session? (not planning-session?)
start-strategy (agent-handler/session-start-strategy block)
message-endpoint (when planning-session?
(or planning-chat-path
(when (string? session-id)
(str "/planning/chat/" session-id))))
session-messages (session->messages session block)
transport (hooks/use-memo
#(chat-transport/make-transport {:base base
:session-id session-id
:open-stream? false
:message-endpoint message-endpoint
:stream-endpoint nil})
[base session-id message-endpoint])
:open-stream? false})
[base session-id])
chat (useChat #js {:id session-id
:transport transport
:messages (clj->js session-messages)})
@@ -678,15 +614,10 @@
[loading-branches? set-loading-branches?!] (rum/use-state false)
[starting-session? set-starting-session?!] (rum/use-state false)
[publish-mode set-publish-mode!] (rum/use-state nil)
[creating-tasks? set-creating-tasks?!] (rum/use-state false)
[planning-action-mode set-planning-action-mode!] (rum/use-state nil)
[planning-note set-planning-note!] (rum/use-state "")
[selected-task-uuids set-selected-task-uuids!] (rum/use-state #{})
[terminal-visible? set-terminal-visible!] (rum/use-state false)
[terminal-status set-terminal-status!] (rum/use-state :idle)
[terminal-error set-terminal-error!] (rum/use-state nil)
[terminal-connection-key set-terminal-connection-key!] (rum/use-state 0)
planning-client-ref (hooks/use-ref nil)
terminal-container-ref (hooks/use-ref nil)
auth-token (state/get-auth-id-token)
terminal-url (agent-handler/terminal-websocket-url base
@@ -695,16 +626,6 @@
terminal-open-disabled? (or (not session-started?)
(not (string? terminal-url)))
trimmed-draft (string/trim (or draft ""))
trimmed-planning-note (string/trim (or planning-note ""))
planner-tasks (planning-session-tasks session session-chat-messages)
executable-planner-task-list (executable-planner-tasks planner-tasks)
selectable-task-uuids (->> executable-planner-task-list
(keep (fn [task]
(some-> (:task-uuid task) normalized-text)))
set)
selected-planner-task-uuids (if (empty? selectable-task-uuids)
#{}
(set/intersection selected-task-uuids selectable-task-uuids))
selected-start-branch (normalized-text start-branch)
branch-select-items (vec (map (fn [branch]
{:value branch
@@ -718,20 +639,6 @@
(not (string? selected-start-branch))
(not (agent-handler/task-ready? block)))
publish-disabled? (or input-disabled? busy? publish-busy?)
create-tasks-disabled? (or creating-tasks? busy? (empty? planner-tasks))
planning-action-busy? (some? planning-action-mode)
approve-disabled? (or planning-action-busy?
(not planning-session?)
(= "approved" (:approval-status session)))
reject-disabled? (or planning-action-busy?
(not planning-session?)
(= "rejected" (:approval-status session)))
replan-disabled? (or planning-action-busy?
(not planning-session?))
execute-selected-disabled? (or planning-action-busy?
creating-tasks?
(empty? planner-tasks)
(empty? selected-planner-task-uuids))
can-send? (and (not input-disabled?)
(not (string/blank? trimmed-draft))
(not busy?))
@@ -739,20 +646,13 @@
send-message! (fn []
(when (and can-send? base session-id)
(set-draft! "")
(if planning-session?
(do
(agent-handler/append-planning-message! block-uuid "user" trimmed-draft)
(when-let [client (hooks/deref planning-client-ref)]
(.send client
(js/JSON.stringify
(clj->js {:content trimmed-draft})))))
(-> (.sendMessage chat #js {:text trimmed-draft})
(.catch (fn [_] nil))))))
(-> (.sendMessage chat #js {:text trimmed-draft})
(.catch (fn [_] nil)))))
start-session! (fn []
(when (and base session-id (not start-session-disabled?))
(set-starting-session?! true)
(let [opts {:base-branch selected-start-branch}]
(-> (agent-handler/<start-auto-session! block opts)
(-> (agent-handler/<start-session! block opts)
(p/then (fn [resp]
(when (and (map? resp)
(string? selected-start-branch)
@@ -787,82 +687,6 @@
(-> (agent-handler/<publish-session! block opts)
(p/catch (fn [_] nil))
(p/finally (fn [] (set-publish-mode! nil)))))))
create-tasks! (fn []
(when-not create-tasks-disabled?
(set-creating-tasks?! true)
(-> (agent-handler/<upsert-planner-tasks! block-uuid
planner-tasks
(cond-> {}
(string? planning-session-id)
(assoc :planning-session-id planning-session-id)))
(p/then (fn [results]
(notification/show! (str "Synced " (count results) " task"
(when (not= 1 (count results)) "s")
".")
:success false)))
(p/catch (fn [error]
(notification/show! (or (some-> error ex-message)
"Failed to create tasks.")
:error false)
nil))
(p/finally (fn [] (set-creating-tasks?! false))))))
planning-action! (fn [mode f success-message error-message]
(set-planning-action-mode! mode)
(-> (f)
(p/then (fn [result]
(when (string? success-message)
(notification/show! success-message :success false))
result))
(p/catch (fn [error]
(notification/show! (or (some-> error ex-message)
error-message)
:error false)
nil))
(p/finally (fn [] (set-planning-action-mode! nil)))))
maybe-send-planning-note! (fn []
(when (and planning-session?
(not (string/blank? trimmed-planning-note)))
(agent-handler/append-planning-message! block-uuid "user" trimmed-planning-note)
(when-let [client (hooks/deref planning-client-ref)]
(.send client
(js/JSON.stringify
(clj->js {:content trimmed-planning-note}))))))
approve-plan! (fn []
(planning-action! :approve
(fn []
(agent-handler/<set-planning-approval! block-uuid
"approved"
{:comment trimmed-planning-note}))
"Planning approved."
"Failed to approve planning."))
reject-plan! (fn []
(planning-action! :reject
(fn []
(agent-handler/<set-planning-approval! block-uuid
"rejected"
{:comment trimmed-planning-note}))
"Planning rejected."
"Failed to reject planning."))
replan! (fn []
(planning-action! :replan
(fn []
(maybe-send-planning-note!)
(agent-handler/<replan-planning-session! block-uuid
{:replan-note trimmed-planning-note}))
"Replanning queued."
"Failed to queue replanning."))
execute-selected! (fn []
(when-not execute-selected-disabled?
(planning-action! :execute
(fn []
(agent-handler/<execute-planned-tasks! block-uuid
planner-tasks
{:planning-session-id planning-session-id
:selected-task-uuids selected-planner-task-uuids}))
(str "Started " (count selected-planner-task-uuids) " planned task"
(when (not= 1 (count selected-planner-task-uuids)) "s")
".")
"Failed to start planned tasks.")))
open-terminal! (fn []
(when (and terminal-enabled? (not terminal-open-disabled?))
(set-terminal-visible! true)
@@ -892,48 +716,6 @@
(agent-handler/<ensure-session! block))
nil)
[block-uuid (:logseq.property/project block) (:logseq.property/agent block) session-created?])
(hooks/use-effect!
(fn []
(when-let [client (hooks/deref planning-client-ref)]
(.close client))
(hooks/set-ref! planning-client-ref nil)
(when (and planning-session?
base
(string? planning-session-id)
(string? auth-token))
(let [client (new AgentClient
(clj->js {:agent "PlanningSessionAgent"
:basePath (str "planning/chat/" planning-session-id)
:host base
:query {:token auth-token}
:onMessage (fn [event]
(when-let [raw (some-> event .-data)]
(when-let [message (parse-json-safe raw)]
(case (:type message)
"planning.state"
(agent-handler/replace-planning-messages! block-uuid
(:state message))
"planning.message"
(agent-handler/replace-planning-messages! block-uuid
(:state message))
nil))))}))]
(hooks/set-ref! planning-client-ref client)))
(fn []
(when-let [client (hooks/deref planning-client-ref)]
(.close client))
(hooks/set-ref! planning-client-ref nil)))
[planning-session? planning-session-id auth-token base block-uuid])
(hooks/use-effect!
(fn []
(when planning-session?
(set-selected-task-uuids! selectable-task-uuids))
nil)
[planning-session?
(pr-str (mapv (fn [task]
(select-keys task [:task-uuid :block-uuid :session-id :status]))
planner-tasks))])
(hooks/use-effect!
(fn []
(let [alive? (atom true)
@@ -978,10 +760,10 @@
[session-id (pr-str session-chat-messages) (pr-str chat-messages)])
(hooks/use-effect!
(fn []
(when (and base session-id session-started? (not planning-session?))
(when (and base session-id session-started?)
(agent-handler/<fetch-events! block))
nil)
[session-id session-started? planning-session?])
[session-id session-started?])
(hooks/use-effect!
(fn []
(when (and terminal-visible?
@@ -993,6 +775,7 @@
dispose-data* (atom nil)
input-seq* (atom 0)
socket (js/WebSocket. terminal-url)
encoder (js/TextEncoder.)
handle-window-resize (fn []
(when-let [fit-addon @fit-addon*]
(try
@@ -1157,11 +940,7 @@
:on-click (fn [_] (start-session!))}
(if starting-session?
"Starting..."
(if (:planning? start-strategy)
"Start agent"
"Start agent")))
[:div.mt-1.text-xs.opacity-70
(:reason start-strategy)]]]
"Start session"))]]
[:<>
[:div.flex.flex-wrap.items-center.gap-2
[:div {:class "inline-flex w-fit items-center gap-1 rounded-lg border border-border bg-muted/40 p-1"}
@@ -1209,67 +988,13 @@
{:size :sm
:variant :outline
:class "h-7 px-2 text-xs"
:disabled create-tasks-disabled?
:disabled publish-disabled?
:on-click (fn [_]
(create-tasks!))}
(if creating-tasks?
"Syncing tasks..."
(if planning-session? "Sync tasks" "Create tasks")))
(when planning-session?
(shui/button
{:size :sm
:variant :outline
:class "h-7 px-2 text-xs"
:disabled execute-selected-disabled?
:on-click (fn [_]
(execute-selected!))}
(if (= planning-action-mode :execute)
"Starting..."
"Execute selected")))
(when planning-session?
(shui/button
{:size :sm
:variant :outline
:class "h-7 px-2 text-xs"
:disabled reject-disabled?
:on-click (fn [_]
(reject-plan!))}
(if (= planning-action-mode :reject)
"Rejecting..."
"Reject")))
(when planning-session?
(shui/button
{:size :sm
:variant :outline
:class "h-7 px-2 text-xs"
:disabled replan-disabled?
:on-click (fn [_]
(replan!))}
(if (= planning-action-mode :replan)
"Replanning..."
"Replan")))
(when planning-session?
(shui/button
{:size :sm
:class "h-7 px-2 text-xs"
:disabled approve-disabled?
:on-click (fn [_]
(approve-plan!))}
(if (= planning-action-mode :approve)
"Approving..."
"Approve")))
(when execution-session?
(shui/button
{:size :sm
:variant :outline
:class "h-7 px-2 text-xs"
:disabled publish-disabled?
:on-click (fn [_]
(publish! false))}
(if (= publish-mode :push)
"Pushing..."
"Push")))
(when (and execution-session? (not pr-created?))
(publish! false))}
(if (= publish-mode :push)
"Pushing..."
"Push"))
(when-not pr-created?
(shui/button
{:size :sm
:class "h-7 px-2 text-xs"
@@ -1279,72 +1004,6 @@
(if (= publish-mode :pr)
"Creating PR..."
"Push + PR")))]
(when planning-session?
[:div {:class "rounded-xl border border-border/70 bg-muted/20 p-3"}
[:div.flex.flex-col.gap-3
[:div.flex.flex-wrap.items-center.justify-between.gap-2
[:div.text-xs.opacity-70
(str "Approval: " (or (:approval-status session) "pending")
" | Tasks: " (count planner-tasks)
" | Selected: " (count selected-planner-task-uuids))]
(when (seq selectable-task-uuids)
[:div.flex.items-center.gap-2.text-xs
(shui/button
{:size :sm
:variant :ghost
:class "h-7 px-2 text-xs"
:on-click (fn [_]
(set-selected-task-uuids! selectable-task-uuids))}
"Select all")
(shui/button
{:size :sm
:variant :ghost
:class "h-7 px-2 text-xs"
:on-click (fn [_]
(set-selected-task-uuids! #{}))}
"Clear")])]
[:textarea.w-full.rounded-lg.border.border-border.bg-background.p-2.text-sm.outline-none
{:rows 3
:value planning-note
:placeholder "Add approval notes or replanning feedback..."
:on-change (fn [e]
(set-planning-note! (.. e -target -value)))}]
(if (seq planner-tasks)
[:div.flex.flex-col.gap-2
(for [task planner-tasks
:let [task-uuid (some-> (:task-uuid task) normalized-text)
selectable? (and (string? task-uuid)
(contains? selectable-task-uuids task-uuid))
checked? (and selectable?
(contains? selected-planner-task-uuids task-uuid))]]
[:label {:key (or task-uuid (str "planned-task-" (random-uuid)))
:class "flex items-start gap-3 rounded-lg border border-border/60 bg-background/70 px-3 py-2"}
[:input.mt-1
{:type "checkbox"
:disabled (not selectable?)
:checked (boolean checked?)
:on-change (fn [e]
(let [checked (.. e -target -checked)]
(set-selected-task-uuids!
(if checked
(conj selected-planner-task-uuids task-uuid)
(disj selected-planner-task-uuids task-uuid)))))}]
[:div.min-w-0.flex-1
[:div.flex.flex-wrap.items-center.gap-2
[:div.font-medium (planning-task-title task)]
[:div {:class "rounded-full bg-muted px-2 py-0.5 text-[10px] uppercase tracking-wide opacity-70"}
(planning-task-status-label task)]]
(when-let [description (some-> (:description task) normalized-text)]
[:div.mt-1.text-xs.opacity-80 description])
[:div {:class "mt-1 flex flex-wrap items-center gap-2 text-[10px] opacity-60"}
(when-let [task-uuid task-uuid]
[:span (str "task " task-uuid)])
(when-let [block-id (some-> (:block-uuid task) str normalized-text)]
[:span (str "block " block-id)])
(when-let [task-session-id (some-> (:session-id task) normalized-text)]
[:span (str "session " task-session-id)])]]])]
[:div.text-xs.opacity-70
"Planning tasks will appear here once the workflow produces a persisted plan."])]])
(when (string? error)
[:div {:class "mt-0.5 rounded-lg border border-red-300/40 bg-red-500/5 px-3 py-1.5 text-xs text-red-500"}
error])

View File

@@ -71,9 +71,6 @@
(goog-define ENABLE-PLUGINS true)
(defonce feature-plugin-system-on? ENABLE-PLUGINS)
(goog-define ENABLE-AGENT-PLANNING false)
(defonce feature-agent-planning-on? ENABLE-AGENT-PLANNING)
;; Desktop only as other platforms requires better understanding of their
;; multi-graph workflows and optimal place for a "global" dir
(def global-config-enabled? util/electron?)

View File

@@ -2,7 +2,6 @@
"Agent sessions for tasks."
(:require [clojure.string :as string]
[electron.ipc :as electron-ipc]
[frontend.config :as config]
[frontend.db :as db]
[frontend.handler.agent-cancel :as agent-cancel]
[frontend.handler.db-based.sync :as db-sync]
@@ -40,25 +39,7 @@
(let [value (string/trim value)]
(when-not (string/blank? value) value))))
(defn- parse-uuid-safe
[value]
(cond
(uuid? value) value
(string? value)
(try
(uuid value)
(catch :default _
nil))
:else nil))
(declare <start-session!
task-session-created?
task-session-id
task-pr-url
maybe-store-task-session-id!
session-state
update-session!
update-session-state!)
(declare <start-session!)
(def ^:private task-sandbox-checkpoint-property :logseq.property/sandbox-checkpoint)
(def ^:private task-session-id-property :logseq.property/agent-session-id)
@@ -89,7 +70,7 @@
(normalize-sandbox-checkpoint checkpoint))
(defn- agent-config
[agent-page opts]
[agent-page]
(let [provider (blank->nil (:block/title agent-page))]
(cond-> {}
(string? provider) (assoc :provider provider))))
@@ -157,10 +138,7 @@
(task-sandbox-checkpoint project-page)))
agent-page (:logseq.property/agent block)
project (when project-page (project-config project-page opts))
agent (when agent-page
(cond-> (agent-config agent-page opts)
(string? (blank->nil (:agent/mode opts))) (assoc :mode (blank->nil (:agent/mode opts)))
(string? (blank->nil (:agent/permission-mode opts))) (assoc :permission-mode (blank->nil (:agent/permission-mode opts)))))]
agent (when agent-page (agent-config agent-page))]
{:block-uuid block-uuid
:node-id node-id
:node-title node-title
@@ -190,50 +168,6 @@
(and (map? agent)
(codex-agent? agent))))
(defn planning-enabled?
[]
(or config/dev?
config/feature-agent-planning-on?
(user-handler/alpha-or-beta-user?)))
(def ^:private planning-intent-pattern
#"(?i)\b(plan|planning|roadmap|break down|decompose|architecture|research|investigate|clarify|phases?|milestones?|workstreams?)\b")
(def ^:private execution-intent-pattern
#"(?i)\b(implement|build|create|fix|refactor|update|change|add|remove|debug|repair|ship)\b")
(defn session-start-strategy
[block]
(let [{:keys [content]} (task-context block)
content (blank->nil content)
lines (if (string? content)
(count (string/split-lines content))
0)
content-length (count (or content ""))
planning-signals (cond-> 0
(and (string? content)
(re-find planning-intent-pattern content)) inc
(> lines 6) inc
(> content-length 260) inc
(and (string? content)
(>= (count (re-seq #"\band\b|," content)) 3)) inc)
_ (prn :debug :planning-signals planning-signals
:content content)
execution-signal? (and (string? content)
(re-find execution-intent-pattern content))
planning? (and (planning-enabled?)
(or (>= planning-signals 2)
(and (>= planning-signals 1)
(not execution-signal?))))]
{:mode (if planning? :planning :execution)
:planning? planning?
:reason (cond
planning?
"Task looks broad enough to benefit from planning first."
:else
"Task looks concrete enough to execute directly.")}))
(defn- login-required-error?
[error]
(let [status (:status (ex-data error))
@@ -382,8 +316,7 @@
(build-session-body block nil))
([block opts]
(let [{:keys [block-uuid node-id node-title content attachments sandbox-checkpoint project agent]} (task-context block opts)
session-id (or (some-> (:session-id opts) blank->nil)
(some-> block-uuid str))]
session-id (some-> block-uuid str)]
(when (and session-id node-id (string? node-title) (string? content) (map? project) (map? agent))
(cond-> {:session-id session-id
:node-id node-id
@@ -396,335 +329,6 @@
:pr-enabled true}}
(map? sandbox-checkpoint) (assoc :sandbox-checkpoint sandbox-checkpoint))))))
(def ^:private planner-default-status :logseq.property/status.todo)
(defn- parse-block-uuid
[value]
(cond
(uuid? value) value
(string? value)
(try
(uuid value)
(catch :default _ nil))
:else nil))
(defn- planner-task-title
[{:keys [title content description]}]
(or (blank->nil title)
(some-> (or (blank->nil content)
(blank->nil description))
(string/split #"\n")
first
blank->nil)))
(defn- planner-task-content
[{:keys [content title description]}]
(or (blank->nil content)
(let [title (blank->nil title)
description (blank->nil description)]
(cond
(and title description) (str title "\n" description)
title title
description description
:else nil))))
(defn- unwrap-json-code-fence
[text]
(when-let [text (blank->nil text)]
(or (some->> text
(re-find #"(?is)```(?:json)?\s*\n(.*?)\n```")
second
blank->nil)
text)))
(defn- parse-json-safe
[text]
(when-let [text (unwrap-json-code-fence text)]
(try
(js->clj (js/JSON.parse text) :keywordize-keys true)
(catch :default _ nil))))
(defn planner-tasks-from-text
[text]
(let [parsed (parse-json-safe text)
tasks (cond
(vector? parsed) parsed
(sequential? (:tasks parsed)) (vec (:tasks parsed))
:else nil)]
(when (seq tasks)
(->> tasks
(keep (fn [task]
(when (map? task)
(let [task' (cond-> {}
(parse-block-uuid (:block-uuid task)) (assoc :block-uuid (parse-block-uuid (:block-uuid task)))
(string? (blank->nil (:title task))) (assoc :title (blank->nil (:title task)))
(string? (blank->nil (:description task))) (assoc :description (blank->nil (:description task)))
(string? (blank->nil (:content task))) (assoc :content (blank->nil (:content task))))]
(when (planner-task-content task')
task')))))
vec
seq
vec))))
(defn- planning-summary-task
[task]
(when (map? task)
(let [title (blank->nil (:title task))
description (blank->nil (:description task))
content (blank->nil (:content task))
task-uuid (blank->nil (:task-uuid task))
block-uuid (blank->nil (:block-uuid task))
session-id (blank->nil (:session-id task))]
(cond-> {}
(string? title) (assoc :title title)
(string? description) (assoc :description description)
(string? content) (assoc :content content)
(string? task-uuid) (assoc :task-uuid task-uuid)
(string? block-uuid) (assoc :block-uuid block-uuid)
(string? session-id) (assoc :session-id session-id)))))
(defn- planning-session-summary
[planning-session]
(let [tasks (or (some-> planning-session :plan :tasks)
[])
tasks (->> tasks
(keep planning-summary-task)
vec)
status (blank->nil (:status planning-session))
approval-status (blank->nil (:approval-status planning-session))
workflow-id (blank->nil (:workflow-id planning-session))
scheduled-actions (or (:scheduled-actions planning-session) [])
header-lines (cond-> []
(string? status) (conj (str "Planning status: " status "."))
(string? approval-status) (conj (str "Approval status: " approval-status "."))
(string? workflow-id) (conj (str "Workflow id: " workflow-id "."))
(seq scheduled-actions) (conj (str "Scheduled actions: "
(pr-str scheduled-actions)
".")))]
(when (or (seq tasks) (seq header-lines))
(str (string/join "\n" header-lines)
(when (seq header-lines) "\n\n")
"```json\n"
(js/JSON.stringify (clj->js {:tasks tasks}) nil 2)
"\n```"))))
(defn- planning-summary-messages
[planning-session]
(when-let [summary (planning-session-summary planning-session)]
[{:id (str "planning-summary-" (or (:planning-session-id planning-session)
(random-uuid)))
:role "assistant"
:parts [{:type "text"
:text summary}]}]))
(defn- planning-chat-message
[message]
(let [content (blank->nil (:content message))
role (blank->nil (:role message))
message-id (blank->nil (:id message))]
(when (and (string? content)
(string? role))
{:id (or message-id (str "planning-message-" (random-uuid)))
:role role
:parts [{:type "text"
:text content}]})))
(defn- planning-chat-messages
[planning-state]
(->> (:messages planning-state)
(keep planning-chat-message)
vec))
(defn- planning-summary-message?
[message]
(string/starts-with? (or (:id message) "") "planning-summary-"))
(defn- merge-planning-summary-messages
[block-uuid planning-session]
(let [existing-messages (or (get-in (session-state block-uuid) [:planning-messages]) [])
non-summary (remove planning-summary-message? existing-messages)]
(into (or (planning-summary-messages planning-session) [])
non-summary)))
(defn- planning-session-state-update
[block-uuid planning-session]
(let [planning-session-id (or (:planning-session-id planning-session)
(:session-id planning-session))
session-id (blank->nil planning-session-id)]
{:session-id session-id
:session-kind "planning"
:planning-session-id session-id
:planning-chat-path (or (blank->nil (:chat-path planning-session))
(when (string? session-id)
(str "/planning/chat/" session-id)))
:workflow-id (:workflow-id planning-session)
:status (:status planning-session)
:plan (:plan planning-session)
:dispatch-sessions (:dispatch-sessions planning-session)
:scheduled-actions (:scheduled-actions planning-session)
:approval-status (:approval-status planning-session)
:require-approval (true? (:require-approval planning-session))
:auto-dispatch (if (boolean? (:auto-dispatch planning-session))
(:auto-dispatch planning-session)
true)
:auto-replan (true? (:auto-replan planning-session))
:replan-delay-sec (:replan-delay-sec planning-session)
:planning-messages (merge-planning-summary-messages block-uuid planning-session)
:planning-loaded? true
:planning-loading? false}))
(defn- enrich-planning-task-state
[task]
(if-let [block (some-> (:block-uuid task) parse-uuid-safe (vector :block/uuid) db/entity)]
(cond-> task
true (assoc :status (or (pu/get-block-property-value block :logseq.property/status)
(:status task)))
(string? (task-session-id block)) (assoc :session-id (task-session-id block))
(string? (task-pr-url block)) (assoc :pr-url (task-pr-url block)))
task))
(defn- apply-planning-session!
[block-uuid planning-session]
(when (map? planning-session)
(let [planning-session (update-in planning-session [:plan :tasks]
(fn [tasks]
(if (sequential? tasks)
(mapv enrich-planning-task-state tasks)
tasks)))]
(doseq [task (get-in planning-session [:plan :tasks])]
(when (and (:block-uuid task) (:session-id task))
(maybe-store-task-session-id! (:block-uuid task) (:session-id task))))
(update-session-state! block-uuid
(planning-session-state-update block-uuid planning-session)))
planning-session))
(defn replace-planning-messages!
[block-uuid planning-state]
(let [existing-summary (->> (get-in (session-state block-uuid) [:planning-messages])
(filter #(string/starts-with? (or (:id %) "") "planning-summary-"))
vec)
messages (into existing-summary (planning-chat-messages planning-state))]
(update-session-state! block-uuid {:planning-messages messages
:planning-agent-state planning-state})))
(defn append-planning-message!
[block-uuid role content]
(when-let [content (blank->nil content)]
(update-session! block-uuid
(fn [session]
(let [messages (vec (or (:planning-messages session) []))]
(assoc session
:planning-messages
(conj messages {:id (str "planning-local-" (random-uuid))
:role role
:parts [{:type "text"
:text content}]}))))
:agent/planning-sessions)))
(defn- planner-goal-context
[goal-block {:keys [project agent] :as _opts}]
{:project (or project (:logseq.property/project goal-block))
:agent (or agent (:logseq.property/agent goal-block))})
(defn- task-tagged?
[block class-ident]
(boolean
(some #(= class-ident (:db/ident %))
(:block/tags block))))
(defn- maybe-set-planner-task-defaults!
[block-uuid {:keys [project agent]}]
(when-let [block (db/entity [:block/uuid block-uuid])]
(when-not (task-tagged? block :logseq.class/Task)
(property-handler/set-block-property! block-uuid :block/tags :logseq.class/Task))
(when-not (pu/get-block-property-value block :logseq.property/status)
(property-handler/set-block-property! block-uuid :logseq.property/status planner-default-status))
(when (and project
(not= (:db/id (:logseq.property/project block))
(:db/id project)))
(property-handler/set-block-property! block-uuid :logseq.property/project (:block/title project)))
(when (and agent
(not= (:db/id (:logseq.property/agent block))
(:db/id agent)))
(property-handler/set-block-property! block-uuid :logseq.property/agent (:block/title agent)))))
(defn- maybe-update-planner-task-content!
[block task]
(when-let [content (planner-task-content task)]
(when (not= (:block/title block) content)
(editor-handler/save-block! (state/get-current-repo) (:block/uuid block) content))))
(defn- <create-planner-task!
[goal-block anchor-block-uuid task context]
(let [target-block-id (or anchor-block-uuid (:db/id goal-block))
sibling? (some? anchor-block-uuid)
content (planner-task-content task)]
(if-not (and target-block-id (string? content))
(p/resolved nil)
(p/let [result (editor-handler/insert-block-tree-after-target target-block-id sibling? [{:content content}] :markdown false)
block-uuid (some-> result :blocks first :block/uuid)]
(when block-uuid
(maybe-set-planner-task-defaults! block-uuid context)
{:block-uuid block-uuid})))))
(defn- <upsert-planner-task!
[goal-block anchor-block-uuid task context]
(if-let [block (when-let [block-uuid (:block-uuid task)]
(db/entity [:block/uuid block-uuid]))]
(if (task-session-created? block)
(p/resolved {:block-uuid (:block/uuid block)})
(do
(maybe-update-planner-task-content! block task)
(maybe-set-planner-task-defaults! (:block/uuid block) context)
(p/resolved {:block-uuid (:block/uuid block)})))
(<create-planner-task! goal-block anchor-block-uuid task context)))
(defn- <sync-planning-task-bindings!
[planning-session-id tasks]
(let [base (db-sync/http-base)
bindings (->> tasks
(keep (fn [task]
(let [task-uuid (blank->nil (:task-uuid task))
block-uuid (some-> (:block-uuid task) str blank->nil)
session-id (blank->nil (:session-id task))]
(when (and (string? task-uuid)
(string? block-uuid))
(cond-> {:task-uuid task-uuid
:block-uuid block-uuid}
(string? session-id) (assoc :session-id session-id))))))
vec)]
(if-not (and (string? base)
(string? (blank->nil planning-session-id))
(seq bindings))
(p/resolved nil)
(db-sync/fetch-json (str base "/planning/sessions/" planning-session-id "/tasks/sync")
{:method "POST"
:headers {"content-type" "application/json"}
:body (js/JSON.stringify (clj->js {:tasks bindings}))}
{:response-schema :planning.sessions/get}))))
(defn <upsert-planner-tasks!
([goal-block-uuid tasks]
(<upsert-planner-tasks! goal-block-uuid tasks nil))
([goal-block-uuid tasks opts]
(if-let [goal-block (db/entity [:block/uuid goal-block-uuid])]
(let [context (planner-goal-context goal-block opts)
planning-session-id (blank->nil (:planning-session-id opts))]
(p/loop [remaining (seq tasks)
acc []
anchor-block-uuid nil]
(if-let [task (first remaining)]
(p/let [result (<upsert-planner-task! goal-block anchor-block-uuid task context)]
(p/recur (next remaining)
(cond-> acc result (conj (merge task result)))
(or (:block-uuid result)
anchor-block-uuid)))
(p/let [_ (<sync-planning-task-bindings! planning-session-id acc)]
(mapv (fn [task]
(select-keys task [:task-uuid :block-uuid :session-id]))
acc)))))
(p/resolved []))))
(def ^:private stream-reconnect-delay-ms 1500)
(defn- session-key [block-uuid]
@@ -850,150 +454,6 @@
(when (and session-id (not= current-session-id session-id))
(property-handler/set-block-property! block-uuid task-session-id-property session-id)))))
(def ^:private planning-started-task-statuses
#{"Doing" "Done" "In Review" "Canceled"})
(defn- planning-task-started?
[task]
(or (string? (blank->nil (:session-id task)))
(contains? planning-started-task-statuses
(some-> (:status task) str string/trim not-empty))))
(defn <fetch-planning-session!
[block-uuid planning-session-id]
(let [base (db-sync/http-base)
planning-session-id (blank->nil planning-session-id)]
(if-not (and (string? base) (string? planning-session-id))
(p/resolved nil)
(p/let [_ (js/Promise. user-handler/task--ensure-id&access-token)
resp (db-sync/fetch-json (str base "/planning/sessions/" planning-session-id)
{:method "GET"}
{:response-schema :planning.sessions/get})]
(apply-planning-session! block-uuid resp)))))
(defn <set-planning-approval!
([block-uuid approval-status]
(<set-planning-approval! block-uuid approval-status nil))
([block-uuid approval-status opts]
(let [base (db-sync/http-base)
planning-session-id (or (some-> (session-state block-uuid) :planning-session-id blank->nil)
(some-> (session-state block-uuid) :session-id blank->nil))
approval-status (some-> approval-status str string/trim string/lower-case not-empty)
approval-comment (some-> (:comment opts) blank->nil)]
(if-not (and (string? base)
(string? planning-session-id)
(contains? #{"pending" "approved" "rejected"} approval-status))
(p/resolved nil)
(p/let [_ (js/Promise. user-handler/task--ensure-id&access-token)
resp (db-sync/fetch-json (str base "/planning/sessions/" planning-session-id "/approval")
{:method "POST"
:headers {"content-type" "application/json"}
:body (js/JSON.stringify
(clj->js (cond-> {:approval {:decision approval-status}}
(string? approval-comment) (assoc-in [:approval :comment] approval-comment))))}
{:response-schema :planning.sessions/get})]
(apply-planning-session! block-uuid resp))))))
(defn <replan-planning-session!
([block-uuid]
(<replan-planning-session! block-uuid nil))
([block-uuid opts]
(let [base (db-sync/http-base)
planning-session-id (or (some-> (session-state block-uuid) :planning-session-id blank->nil)
(some-> (session-state block-uuid) :session-id blank->nil))
replan-note (some-> (:replan-note opts) blank->nil)]
(if-not (and (string? base)
(string? planning-session-id))
(p/resolved nil)
(p/let [_ (js/Promise. user-handler/task--ensure-id&access-token)
resp (db-sync/fetch-json (str base "/planning/sessions/" planning-session-id "/replan")
{:method "POST"
:headers {"content-type" "application/json"}
:body (js/JSON.stringify
(clj->js (cond-> {}
(string? replan-note) (assoc :replan-note replan-note))))}
{:response-schema :planning.sessions/get})]
(apply-planning-session! block-uuid resp))))))
(defn- <start-planned-task!
[goal-block-uuid task]
(let [goal-block (db/entity [:block/uuid goal-block-uuid])
parent-session-id (or (some-> goal-block task-session-id blank->nil)
(some-> goal-block-uuid str blank->nil))
block-uuid (:block-uuid task)
block (when block-uuid
(db/entity [:block/uuid block-uuid]))
existing-session-id (or (blank->nil (:session-id task))
(some-> goal-block task-session-id blank->nil)
(some-> block task-session-id blank->nil))]
(cond
(not block)
(p/resolved nil)
(or existing-session-id
(task-session-created? block)
(planning-task-started? task))
(let [session-id (or existing-session-id
(task-session-id block))]
(when (string? session-id)
(when goal-block-uuid
(maybe-store-task-session-id! goal-block-uuid session-id))
(maybe-store-task-session-id! block-uuid session-id))
(p/resolved (cond-> (select-keys task [:task-uuid :block-uuid])
(string? session-id) (assoc :session-id session-id))))
:else
(p/let [resp (<start-session! block {:session-id parent-session-id})
session-id (or (some-> resp :session-id blank->nil)
(some-> (db/entity [:block/uuid goal-block-uuid])
task-session-id
blank->nil)
(some-> (db/entity [:block/uuid block-uuid])
task-session-id
blank->nil))]
(when (string? session-id)
(when goal-block-uuid
(maybe-store-task-session-id! goal-block-uuid session-id))
(maybe-store-task-session-id! block-uuid session-id))
(cond-> (select-keys task [:task-uuid :block-uuid])
(string? session-id) (assoc :session-id session-id))))))
(defn <execute-planned-tasks!
([goal-block-uuid tasks]
(<execute-planned-tasks! goal-block-uuid tasks nil))
([goal-block-uuid tasks opts]
(let [planning-session-id (blank->nil (:planning-session-id opts))
selected-task-uuids (set (keep blank->nil (:selected-task-uuids opts)))]
(p/let [persisted-tasks (<upsert-planner-tasks! goal-block-uuid
tasks
{:planning-session-id planning-session-id})
persisted-by-task-uuid (into {}
(keep (fn [task]
(when-let [task-uuid (blank->nil (:task-uuid task))]
[task-uuid task])))
persisted-tasks)
selected-tasks (->> tasks
(map (fn [task]
(merge task
(get persisted-by-task-uuid
(blank->nil (:task-uuid task))))))
(filter (fn [task]
(or (empty? selected-task-uuids)
(contains? selected-task-uuids
(blank->nil (:task-uuid task))))))
vec)
results (p/loop [remaining selected-tasks
acc []]
(if-let [task (first remaining)]
(p/let [result (<start-planned-task! goal-block-uuid task)]
(p/recur (next remaining)
(cond-> acc result (conj result))))
acc))
sync-response (<sync-planning-task-bindings! planning-session-id results)]
(when (map? sync-response)
(apply-planning-session! goal-block-uuid sync-response))
results))))
(defn- maybe-store-task-sandbox-checkpoint!
[block-uuid checkpoint]
(when-let [block (db/entity [:block/uuid block-uuid])]
@@ -1013,34 +473,16 @@
(checkpoint->property-value checkpoint))))))))
(defn- update-session!
([block-uuid f]
(update-session! block-uuid f nil))
([block-uuid f bucket-key]
(let [bucket-key (or bucket-key :agent/sessions)]
(state/update-state! bucket-key
(fn [sessions]
(let [key (session-key block-uuid)
session (get sessions key {})]
(assoc sessions key (f session))))))))
(defn- session-bucket-key
[block-uuid data]
(let [session-kind (some-> (:session-kind data)
blank->nil
string/lower-case)
planning-sessions (state/sub :agent/planning-sessions)
key (session-key block-uuid)]
(cond
(= "planning" session-kind) :agent/planning-sessions
(= "session" session-kind) :agent/sessions
(contains? planning-sessions key) :agent/planning-sessions
:else :agent/sessions)))
[block-uuid f]
(state/update-state! :agent/sessions
(fn [sessions]
(let [key (session-key block-uuid)
session (get sessions key {})]
(assoc sessions key (f session))))))
(defn- update-session-state!
[block-uuid data]
(update-session! block-uuid
#(merge % data)
(session-bucket-key block-uuid data)))
(update-session! block-uuid #(merge % data)))
(defn- event->status [event]
(case (:type event)
@@ -1114,9 +556,7 @@
:kind "user"}))
(defn- session-state [block-uuid]
(let [key (session-key block-uuid)]
(or (get (state/sub :agent/planning-sessions) key)
(get (state/sub :agent/sessions) key))))
(get (state/sub :agent/sessions) (session-key block-uuid)))
(defn <fetch-events!
[block]
@@ -1278,21 +718,11 @@
(<connect-session-stream! block-uuid stream-url))))
stream-reconnect-delay-ms))
(defn- planning-session?
[session]
(let [session-kind (some-> (:session-kind session)
str
string/trim
string/lower-case)]
(or (= "planning" session-kind)
(string? (blank->nil (:planning-chat-path session))))))
(defn <ensure-session!
[block]
(let [block-uuid (:block/uuid block)
base (db-sync/http-base)
session (session-state block-uuid)
planning? (planning-session? session)
session-id (or (:session-id session)
(task-session-id block)
(some-> block-uuid str))]
@@ -1302,81 +732,47 @@
(p/resolved nil)
(:session-id session)
(if planning?
(do
(when (and (not (:planning-loaded? session))
(not (:planning-loading? session)))
(update-session-state! block-uuid {:planning-loading? true})
(-> (db-sync/fetch-json (str base "/planning/sessions/" session-id)
{:method "GET"}
{:response-schema :planning.sessions/get})
(p/then (fn [resp]
(apply-planning-session! block-uuid resp)))
(p/catch (fn [error]
(update-session-state! block-uuid {:planning-loading? false})
(let [status (:status (ex-data error))]
(when-not (= status 404)
(log/error :agent/ensure-planning-session-failed error)))
nil))))
(p/resolved session))
(do
(maybe-store-task-session-id! block-uuid (:session-id session))
(when-not (:streaming? session)
(-> (<fetch-events! block)
(p/then (fn [_]
(<connect-session-stream! block-uuid (or (:stream-url (session-state block-uuid))
(session-stream-url base session-id)))))))
(p/resolved session)))
(do
(maybe-store-task-session-id! block-uuid (:session-id session))
(when-not (:streaming? session)
(-> (<fetch-events! block)
(p/then (fn [_]
(<connect-session-stream! block-uuid (or (:stream-url (session-state block-uuid))
(session-stream-url base session-id)))))))
(p/resolved session))
:else
(if planning?
(p/resolved nil)
(do
(update-session-state! block-uuid {:loading? true})
(-> (p/let [resp (db-sync/fetch-json (str base "/sessions/" session-id)
{:method "GET"}
{:response-schema :sessions/get})
session-id' (or (:session-id resp) session-id)
stream-url (session-stream-url base session-id')]
(update-session-state! block-uuid {:session-id session-id'
:status (:status resp)
:runtime-provider (:runtime-provider resp)
:terminal-enabled (true? (:terminal-enabled resp))
:stream-url stream-url
:loading? false})
(maybe-store-task-session-id! block-uuid session-id')
(maybe-update-task-status! block-uuid (:status resp))
(<fetch-events! block)
(<connect-session-stream! block-uuid (or (:stream-url (session-state block-uuid))
stream-url))
resp)
(p/catch (fn [error]
(update-session-state! block-uuid {:loading? false})
(let [status (:status (ex-data error))]
(when-not (= status 404)
(log/error :agent/ensure-session-failed error)))
nil)))))))))
(defn- <ensure-auth!
[]
(js/Promise. user-handler/task--ensure-id&access-token))
(defn- planning-create-path?
[create-path]
(= "/planning/sessions" (some-> create-path str string/trim)))
(do
(update-session-state! block-uuid {:loading? true})
(-> (p/let [resp (db-sync/fetch-json (str base "/sessions/" session-id)
{:method "GET"}
{:response-schema :sessions/get})
session-id' (or (:session-id resp) session-id)
stream-url (session-stream-url base session-id')]
(update-session-state! block-uuid {:session-id session-id'
:status (:status resp)
:runtime-provider (:runtime-provider resp)
:terminal-enabled (true? (:terminal-enabled resp))
:stream-url stream-url
:loading? false})
(maybe-store-task-session-id! block-uuid session-id')
(maybe-update-task-status! block-uuid (:status resp))
(<fetch-events! block)
(<connect-session-stream! block-uuid (or (:stream-url (session-state block-uuid))
stream-url))
resp)
(p/catch (fn [error]
(update-session-state! block-uuid {:loading? false})
(let [status (:status (ex-data error))]
(when-not (= status 404)
(log/error :agent/ensure-session-failed error)))
nil))))))))
(defn <start-session!
([block]
(<start-session! block nil))
([block opts]
(<start-session! block opts "/sessions"))
([block opts create-path]
(let [base (db-sync/http-base)
opts (or opts {})
planning? (planning-create-path? create-path)
response-schema (if planning?
:planning.sessions/create
:sessions/create)]
(let [base (db-sync/http-base)]
(cond
(not base)
(do
@@ -1389,78 +785,39 @@
(p/resolved nil))
:else
(p/let [_ (<ensure-auth!)
(p/let [_ (js/Promise. user-handler/task--ensure-id&access-token)
raw-body (build-session-body block opts)
body (coerce-http-request :sessions/create raw-body)]
(if (nil? body)
(do
(notification/show! "Invalid agent session payload." :error false)
nil)
(-> (p/let [resp (db-sync/fetch-json (str base create-path)
(-> (p/let [resp (db-sync/fetch-json (str base "/sessions")
{:method "POST"
:headers {"content-type" "application/json"}
:body (js/JSON.stringify (clj->js body))}
{:response-schema response-schema})
session-id (or (:session-id resp)
(:planning-session-id resp))
{:response-schema :sessions/create})
session-id (:session-id resp)
status (:status resp)
stream-url (when-not planning?
(:stream-url resp))
planning-chat-path (when planning?
(or (blank->nil (:chat-path resp))
(when (string? session-id)
(str "/planning/chat/" session-id))))
stream-url (:stream-url resp)
block-uuid (:block/uuid block)
_ (when (and (not planning?)
(string? session-id))
(when-let [raw-message (message-body (:content raw-body))]
(let [coerced (coerce-http-request :sessions/message raw-message)
msg-body (if (map? coerced) coerced raw-message)]
(db-sync/fetch-json (str base "/sessions/" session-id "/messages")
{:method "POST"
:headers {"content-type" "application/json"}
:body (js/JSON.stringify (clj->js msg-body))}
{:response-schema :sessions/message}))))]
_ (when-let [raw-message (message-body (:content raw-body))]
(let [coerced (coerce-http-request :sessions/message raw-message)
msg-body (if (map? coerced) coerced raw-message)]
(db-sync/fetch-json (str base "/sessions/" session-id "/messages")
{:method "POST"
:headers {"content-type" "application/json"}
:body (js/JSON.stringify (clj->js msg-body))}
{:response-schema :sessions/message})))]
(notification/clear! github-install-required-notification-uid)
(if planning?
(update-session-state! block-uuid {:session-id session-id
:session-kind "planning"
:planning-session-id session-id
:planning-chat-path planning-chat-path
:workflow-id (:workflow-id resp)
:status status
:approval-status (:approval-status resp)
:require-approval (true? (:require-approval resp))
:auto-dispatch (if (boolean? (:auto-dispatch resp))
(:auto-dispatch resp)
true)
:auto-replan (true? (:auto-replan resp))
:replan-delay-sec (:replan-delay-sec resp)
:runtime-provider nil
:terminal-enabled false
:stream-url nil
:streaming? false
:stream-controller nil
:plan nil
:dispatch-sessions nil
:scheduled-actions nil
:planning-messages nil
:planning-loaded? false
:planning-loading? false
:started-at (util/time-ms)})
(update-session-state! block-uuid {:session-id session-id
:session-kind "session"
:planning-session-id nil
:planning-chat-path nil
:status status
:runtime-provider (:runtime-provider resp)
:terminal-enabled (true? (:terminal-enabled resp))
:stream-url stream-url
:started-at (util/time-ms)}))
(when (and (not planning?)
(string? session-id))
(maybe-store-task-session-id! block-uuid session-id)
(<connect-session-stream! block-uuid stream-url))
(update-session-state! block-uuid {:session-id session-id
:status status
:runtime-provider (:runtime-provider resp)
:terminal-enabled (true? (:terminal-enabled resp))
:stream-url stream-url
:started-at (util/time-ms)})
(maybe-store-task-session-id! block-uuid session-id)
(<connect-session-stream! block-uuid stream-url)
resp)
(p/catch (fn [error]
(cond
@@ -1473,34 +830,12 @@
(show-github-install-required-notification!
error
(fn []
(-> (<start-session! block opts create-path)
(-> (<start-session! block opts)
(p/catch (fn [_] nil)))))
:else
(notification/show! (start-session-error-message error) :error false))
nil)))))))))
(defn <start-planning-session!
([block]
(<start-planning-session! block nil))
([block opts]
(if-not (planning-enabled?)
(do
(notification/show! "Planning is not enabled for this build/account." :warning false)
(p/resolved nil))
(let [opts (cond-> (or opts {})
(nil? (:agent/permission-mode opts))
(assoc :agent/permission-mode "read-only"))]
(<start-session! block opts "/planning/sessions")))))
(defn <start-auto-session!
([block]
(<start-auto-session! block nil))
([block opts]
(let [{:keys [planning?]} (session-start-strategy block)]
(if planning?
(<start-planning-session! block opts)
(<start-session! block opts)))))
(defn- publish-request-body
[{:keys [title body commit-message head-branch base-branch create-pr? force?]}]
(cond-> {:create-pr (if (nil? create-pr?) true (true? create-pr?))

View File

@@ -126,21 +126,6 @@
[base session-id]
(str base "/sessions/" session-id "/messages"))
(defn- endpoint-url
[base endpoint]
(let [endpoint (non-empty-str endpoint)]
(cond
(and (string? endpoint)
(or (string/starts-with? endpoint "http://")
(string/starts-with? endpoint "https://")))
endpoint
(and (string? base) (string? endpoint))
(str base endpoint)
:else
nil)))
(defn- runtime-error-message
[event]
(let [data (if (map? (:data event)) (:data event) {})]
@@ -588,14 +573,7 @@
(.finally (fn [] nil))))))
(defn- send-messages!
[{:keys [base
session-id
fetch-fn
now-fn
idle-timeout-ms
open-stream?
message-endpoint
stream-endpoint]} opts]
[{:keys [base session-id fetch-fn now-fn idle-timeout-ms open-stream?]} opts]
(let [message (last-user-message-text (aget opts "messages"))]
(if-not (and (string? base) (string? session-id))
(js/Promise.reject
@@ -607,10 +585,8 @@
start-ts (now-fn)
abort-signal (aget opts "abortSignal")
headers (auth-headers)
post-url (or (endpoint-url base message-endpoint)
(messages-url base session-id))
stream-url' (or (endpoint-url base stream-endpoint)
(stream-url base session-id))]
post-url (messages-url base session-id)
stream-endpoint (stream-url base session-id)]
(-> (p/let [post-resp (fetch-fn post-url
#js {:method "POST"
:headers headers
@@ -622,45 +598,34 @@
(throw (js/Error. (str "send message failed: " (.-status post-resp)))))]
(if (false? open-stream?)
(chunks->readable-stream [{:type "finish"}])
(if-not (string? stream-url')
(chunks->readable-stream [{:type "finish"}])
(p/let [stream-resp (fetch-fn stream-url'
#js {:method "GET"
:headers headers
:signal abort-signal})
_ (when-not (.-ok stream-resp)
(throw (js/Error. (str "open stream failed: " (.-status stream-resp)))))
_ (when-not (.-body stream-resp)
(throw (js/Error. "stream response has no body")))]
(let [ts (js/TransformStream.)
writer (.getWriter (.-writable ts))]
(start-stream-consumer! {:response stream-resp
:writer writer
:start-ts start-ts
:idle-timeout-ms (or idle-timeout-ms default-idle-timeout-ms)
:abort-signal abort-signal})
(.-readable ts))))))
(p/let [stream-resp (fetch-fn stream-endpoint
#js {:method "GET"
:headers headers
:signal abort-signal})
_ (when-not (.-ok stream-resp)
(throw (js/Error. (str "open stream failed: " (.-status stream-resp)))))
_ (when-not (.-body stream-resp)
(throw (js/Error. "stream response has no body")))]
(let [ts (js/TransformStream.)
writer (.getWriter (.-writable ts))]
(start-stream-consumer! {:response stream-resp
:writer writer
:start-ts start-ts
:idle-timeout-ms (or idle-timeout-ms default-idle-timeout-ms)
:abort-signal abort-signal})
(.-readable ts)))))
(.catch (fn [error]
(js/Promise.reject error)))))))))
(defn make-transport
[{:keys [base
session-id
fetch-fn
now-fn
idle-timeout-ms
open-stream?
message-endpoint
stream-endpoint]}]
[{:keys [base session-id fetch-fn now-fn idle-timeout-ms open-stream?]}]
#js {:sendMessages (fn [opts]
(send-messages! {:base base
:session-id session-id
:fetch-fn fetch-fn
:now-fn now-fn
:idle-timeout-ms idle-timeout-ms
:open-stream? open-stream?
:message-endpoint message-endpoint
:stream-endpoint stream-endpoint}
:open-stream? open-stream?}
opts))
:reconnectToStream (fn [_opts]
(js/Promise.resolve nil))})

View File

@@ -293,7 +293,6 @@
:rtc/asset-upload-download-progress (atom {})
:rtc/users-info (atom {})
:agent/sessions (atom {})
:agent/planning-sessions (atom {})
:user/info {:UserGroups (storage/get :user-groups)}
:encryption/graph-parsing? false

View File

@@ -1,110 +0,0 @@
(ns frontend.handler.agent-test
(:require [clojure.string :as string]
[clojure.test :refer [deftest is testing use-fixtures]]
[frontend.db :as db]
[frontend.handler.agent :as agent-handler]
[frontend.handler.db-based.property :as db-property-handler]
[frontend.handler.property.util :as pu]
[frontend.test.helper :as test-helper :include-macros true :refer [deftest-async]]
[promesa.core :as p]))
(use-fixtures :each {:before test-helper/start-test-db!
:after (fn [] (test-helper/destroy-test-db!))})
(defn- setup-goal-context!
[]
(test-helper/load-test-files [{:page {:block/title "Goal Page"}
:blocks [{:block/title "Goal"}]}
{:page {:block/title "Project Alpha"}}
{:page {:block/title "Codex"}}])
(let [goal (test-helper/find-block-by-content "Goal")
project-page (db/get-page "Project Alpha")
agent-page (db/get-page "Codex")]
(db-property-handler/set-block-property! (:db/id project-page) :block/tags :logseq.class/Project)
(db-property-handler/set-block-property! (:db/id project-page) :logseq.property/git-repo "https://github.com/logseq/logseq")
(db-property-handler/set-block-property! (:db/id agent-page) :block/tags :logseq.class/Agent)
(db-property-handler/set-block-property! (:db/id goal) :logseq.property/project "Project Alpha")
(db-property-handler/set-block-property! (:db/id goal) :logseq.property/agent "Codex")
{:goal goal
:project-page project-page
:agent-page agent-page}))
(deftest-async persist-planner-tasks-creates-runnable-task-blocks-test
(p/let [{:keys [goal]} (setup-goal-context!)
tasks (agent-handler/<upsert-planner-tasks!
(:block/uuid goal)
[{:title "Implement planner tasks"
:description "Create Logseq tasks from planner output"}])
task-uuid (:block-uuid (first tasks))
task-block (db/entity [:block/uuid task-uuid])]
(is (= 1 (count tasks)))
(is (some #(= :logseq.class/Task (:db/ident %)) (:block/tags task-block)))
(is (= "Todo" (pu/get-block-property-value task-block :logseq.property/status)))
(is (= "Project Alpha" (some-> (:logseq.property/project task-block) :block/title)))
(is (= "Codex" (some-> (:logseq.property/agent task-block) :block/title)))
(is (some? (agent-handler/build-session-body task-block)))))
(deftest-async upsert-planner-tasks-reconciles-existing-task-by-block-uuid-test
(p/let [{:keys [goal]} (setup-goal-context!)
created (agent-handler/<upsert-planner-tasks!
(:block/uuid goal)
[{:title "Initial title"
:description "Initial description"}])
block-uuid (:block-uuid (first created))
_ (agent-handler/<upsert-planner-tasks!
(:block/uuid goal)
[{:block-uuid block-uuid
:title "Updated title"
:description "Updated description"}])
updated (db/entity [:block/uuid block-uuid])]
(is (= "Updated title\nUpdated description" (:block/title updated)))
(is (= "Todo" (pu/get-block-property-value updated :logseq.property/status)))))
(deftest-async upsert-planner-tasks-does-not-overwrite-task-after-session-start-test
(p/let [{:keys [goal]} (setup-goal-context!)
created (agent-handler/<upsert-planner-tasks!
(:block/uuid goal)
[{:title "Locked task"
:description "Original"}])
block-uuid (:block-uuid (first created))
_ (db-property-handler/set-block-property! block-uuid :logseq.property/agent-session-id "sess-1")
_ (agent-handler/<upsert-planner-tasks!
(:block/uuid goal)
[{:block-uuid block-uuid
:title "Changed title"
:description "Changed description"}])
unchanged (db/entity [:block/uuid block-uuid])]
(is (= "Locked task\nOriginal" (:block/title unchanged)))
(is (= "sess-1" (agent-handler/task-session-id unchanged)))))
(deftest-async build-session-body-includes-explicit-agent-permission-mode-test
(p/let [{:keys [goal]} (setup-goal-context!)
created (agent-handler/<upsert-planner-tasks!
(:block/uuid goal)
[{:title "Planning task"
:description "Plan safely"}])
task-block (db/entity [:block/uuid (:block-uuid (first created))])
body (agent-handler/build-session-body task-block {:agent/permission-mode "read-only"})]
(is (= "codex" (some-> body :agent :provider string/lower-case)))
(is (= "read-only" (get-in body [:agent :permission-mode])))))
(deftest planner-tasks-from-text-parses-json-object-and-code-fence-test
(testing "json object with tasks"
(is (= [{:title "Task A"
:description "Desc A"}
{:title "Task B"
:content "Explicit content"}]
(agent-handler/planner-tasks-from-text
"{\"tasks\":[{\"title\":\"Task A\",\"description\":\"Desc A\"},{\"title\":\"Task B\",\"content\":\"Explicit content\"}]}"))))
(testing "fenced json array"
(is (= [{:title "Task C"
:description "Desc C"}]
(agent-handler/planner-tasks-from-text
"```json\n[{\"title\":\"Task C\",\"description\":\"Desc C\"}]\n```"))))
(testing "prose with fenced json block"
(is (= [{:title "Task D"
:description "Desc D"}]
(agent-handler/planner-tasks-from-text
"Here is the plan.\n\n```json\n{\"tasks\":[{\"title\":\"Task D\",\"description\":\"Desc D\"}]}\n```\n\nReview it."))))
(testing "invalid payload returns nil"
(is (nil? (agent-handler/planner-tasks-from-text "not planner json")))))

529
yarn.lock

File diff suppressed because it is too large Load Diff