Files
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00:00

560 lines
22 KiB
Python

"""Co4E — node-graph workflow engine (ported from nova-platform's Flow feature).
A Co4E *workflow* is a graph of step nodes joined by edges. Steps run in
topological **waves** (all nodes at the same depth run together); a *parallel*
node fans out into one stage per sub-agent plus an optional join stage the wave
after. Each node names an agent persona (built-in or custom), optional attached
skills, a model, a permission preset and instructions; at run time these compile
into per-stage prompts fed to the agent, with each wave's outputs threaded into
the next wave's prompts.
This module is PURE PYTHON (no Qt) so the model, store, wave computation and
stage compilation are all unit-testable. UI lives in ``ui/co4e_*`` and the
runner that actually calls the provider lives in ``core/co4e_runner.py``.
Persistence: one JSON file per workflow under ``~/.cowork_local/co4e/workflows``
and one per custom agent under ``~/.cowork_local/co4e/agents``. Skills reuse the
existing ``core/skills.py`` registry.
"""
from __future__ import annotations
from ..infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
from ..config import CONFIG_DIR
CO4E_DIR = CONFIG_DIR / "co4e"
WORKFLOWS_DIR = CO4E_DIR / "workflows"
AGENTS_DIR = CO4E_DIR / "agents"
# ---- enums ---------------------------------------------------------------
STEP_IDLE, STEP_PENDING, STEP_RUNNING, STEP_DONE, STEP_ERROR, STEP_PLANNED = (
"idle", "pending", "running", "done", "error", "planned")
PERMISSION_PRESETS = ("inherit", "read-only", "standard", "full")
# preset -> allowed tool names (None = all tools; enforced by run_cowork's
# allowed_tools filter — update_plan is always allowed on top of these since it
# only drives the plan panel and touches nothing). "save_file" is Cowork's way
# of writing a deliverable, so it belongs to "standard" (write) but NOT "read-only".
PRESET_SCOPES: Dict[str, Optional[List[str]]] = {
"inherit": None,
"read-only": ["read_file", "list_dir", "fetch_url"],
"standard": ["read_file", "list_dir", "write_file", "edit_file", "save_file", "fetch_url"],
"full": None,
}
# auto — each step's agent plans then executes automatically (default)
# plan — read-only: each step only drafts a plan, nothing is written
# manual — step-by-step: run one step at a time, review, then advance ("Next step")
RUN_MODES = ("auto", "plan", "manual")
def slugify(value: str) -> str:
"""Chuyển một chuỗi thành slug an toàn cho tên file: chỉ chữ/số/gạch, gộp gạch
liên tiếp. Rỗng thì trả về 'step' để không bao giờ sinh ra tên file trống.
"""
s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in (value or "").strip().lower())
return "-".join(filter(None, s.split("-"))) or "step"
# ---- data model ----------------------------------------------------------
@dataclass
class SubAgent:
"""One concurrent worker inside a parallel node."""
agent: str = "" # palette agent NAME (built-in or custom)
instructions: str = "" # per-sub-agent extra instructions
@dataclass
class Step:
"""A node's persona/config (mirrors nova StepNodeData)."""
variant: str = "step" # "step" | "parallel"
label: str = "New Step"
agent_slug: str = "custom-step"
role: str = "AGENT" # UPPERCASE badge
icon: str = "" # line-icon name ("" = role default)
instructions: str = ""
context: str = "" # extra background/info injected into the prompt
model: str = "" # "" = active provider's default model
# Auto self-check each step before advancing (Claude-CLI-style quality gate) —
# ON by default so a flow verifies (and fixes) each step's work automatically.
self_verify: bool = True
max_verify_rounds: int = 1
permission_preset: str = "full" # steps default to full workspace access
skills: List[str] = field(default_factory=list) # registry skill NAMES
attachments: List[str] = field(default_factory=list) # local file paths fed to the step
sub_agents: List[SubAgent] = field(default_factory=list) # parallel only
@property
def is_parallel(self) -> bool:
"""Bước này có chạy nhiều sub-agent song song hay không."""
return self.variant == "parallel"
@dataclass
class Node:
"""Một node trên khung vẽ: id, toạ độ, và bước (:class:`Step`) mà nó đại diện."""
id: str
x: float = 0.0
y: float = 0.0
data: Step = field(default_factory=Step)
@dataclass
class Edge:
"""Một cạnh nối hai node, quy định thứ tự chạy giữa chúng."""
id: str
source: str
target: str
@dataclass
class Workflow:
"""Một luồng Co4E: danh sách node, cạnh, và cờ đánh dấu đây có phải mẫu không."""
id: str
name: str = "Untitled flow"
is_template: bool = False
nodes: List[Node] = field(default_factory=list)
edges: List[Edge] = field(default_factory=list)
@dataclass
class CustomAgent:
"""A persisted custom Co4E agent persona (mirrors nova CustomFlowAgent)."""
id: str
name: str = ""
role: str = "AGENT"
instructions: str = ""
context: str = "" # extra background/info injected into the prompt
model: str = ""
permission_preset: str = "full"
icon: str = ""
skills: List[str] = field(default_factory=list)
attachments: List[str] = field(default_factory=list) # local file paths fed to the agent
# ---- (de)serialization ---------------------------------------------------
def step_from_dict(d: dict) -> Step:
"""Dựng :class:`Step` từ dict đọc trên đĩa.
Lọc bỏ khoá lạ để file luồng của phiên bản mới hơn không làm vỡ bản cũ.
"""
d = dict(d or {})
subs = d.pop("sub_agents", None) or []
known = Step().__dict__.keys()
step = Step(**{k: v for k, v in d.items() if k in known})
step.sub_agents = [
SubAgent(agent=s.get("agent", ""), instructions=s.get("instructions", ""))
if isinstance(s, dict) else SubAgent(agent=str(s))
for s in subs
]
return step
def node_from_dict(d: dict) -> Node:
"""Dựng :class:`Node` từ dict đọc trên đĩa."""
return Node(id=str(d.get("id", "")), x=float(d.get("x", 0) or 0),
y=float(d.get("y", 0) or 0), data=step_from_dict(d.get("data", {})))
def workflow_from_dict(d: dict) -> Workflow:
"""Dựng :class:`Workflow` từ dict đọc trên đĩa."""
return Workflow(
id=str(d.get("id", "")),
name=d.get("name", "Untitled flow"),
is_template=bool(d.get("is_template", False)),
nodes=[node_from_dict(n) for n in d.get("nodes", [])],
edges=[Edge(id=str(e.get("id", "")), source=str(e.get("source", "")),
target=str(e.get("target", ""))) for e in d.get("edges", [])],
)
def workflow_to_dict(wf: Workflow) -> dict:
"""Chuyển một luồng thành dict để ghi JSON."""
return {
"id": wf.id, "name": wf.name, "is_template": wf.is_template,
"nodes": [{"id": n.id, "x": n.x, "y": n.y, "data": _step_dict(n.data)} for n in wf.nodes],
"edges": [asdict(e) for e in wf.edges],
}
def _step_dict(step: Step) -> dict:
"""Chuyển một bước thành dict; ``asdict`` đã tự chuyển ``sub_agents`` thành list dict."""
d = asdict(step)
# asdict already turns sub_agents into list[dict]
return d
def agent_to_dict(a: CustomAgent) -> dict:
"""Chuyển một agent tự tạo thành dict để ghi JSON."""
return asdict(a)
def agent_from_dict(d: dict) -> CustomAgent:
"""Dựng :class:`CustomAgent` từ dict, lọc bỏ khoá lạ."""
known = CustomAgent(id="").__dict__.keys()
d = {k: v for k, v in (d or {}).items() if k in known}
d.setdefault("id", "")
a = CustomAgent(**d)
a.skills = list(a.skills or [])
a.attachments = list(a.attachments or [])
return a
# ---- id minting (no time/random — deterministic counter per process) -----
_counter = {"n": 0}
def _mint_id(prefix: str) -> str:
"""Sinh id tăng dần dạng ``<prefix>_000001``."""
_counter["n"] += 1
return f"{prefix}_{_counter['n']:06d}"
def new_node_id() -> str:
"""Id mới cho một node."""
return _mint_id("node")
def new_edge_id(source: str, target: str) -> str:
"""Id cạnh suy ra TỪ cặp nguồn/đích.
Cố ý không ngẫu nhiên: nhờ vậy nối lại đúng cặp node đó luôn cho ra cùng
một id, và không thể sinh ra hai cạnh trùng nhau.
"""
return f"e_{source}__{target}"
def new_workflow(name: str = "Untitled flow") -> Workflow:
"""Tạo một luồng rỗng với id mới."""
return Workflow(id=_mint_id("wf"), name=name)
def new_custom_agent(name: str = "") -> CustomAgent:
"""Tạo một agent tự tạo rỗng với id mới."""
return CustomAgent(id=_mint_id("agent"), name=name)
# ---- workflow store ------------------------------------------------------
def workflows_dir() -> Path:
"""Thư mục chứa file luồng."""
return WORKFLOWS_DIR
def list_workflows(directory: Optional[Path] = None) -> List[Workflow]:
"""Liệt kê mọi luồng đã lưu; thư mục chưa có thì trả list rỗng."""
directory = directory or WORKFLOWS_DIR
if not directory.exists():
return []
out: List[Workflow] = []
for path in sorted(directory.glob("*.json")):
try:
out.append(workflow_from_dict(json.loads(path.read_text(encoding="utf-8"))))
except (OSError, json.JSONDecodeError, TypeError, ValueError):
continue
return out
def save_workflow(wf: Workflow, directory: Optional[Path] = None) -> Path:
"""Ghi một luồng ra ``<id>.json``, tự tạo thư mục nếu chưa có."""
directory = directory or WORKFLOWS_DIR
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{wf.id}.json"
# Tiêu chí nghiệm thu A: mọi thao tác ghi tệp đi qua AtomicJsonFile. Trước
# đây ghi thẳng, nên tắt máy giữa lúc lưu là mất luôn workflow.
AtomicJsonFile(path).write(workflow_to_dict(wf))
return path
def get_workflow(wf_id: str, directory: Optional[Path] = None) -> Optional[Workflow]:
"""Đọc một luồng theo id; ``None`` nếu không có."""
directory = directory or WORKFLOWS_DIR
path = directory / f"{wf_id}.json"
if not path.exists():
return None
try:
return workflow_from_dict(json.loads(path.read_text(encoding="utf-8")))
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return None
def duplicate_workflow(wf: Workflow, directory: Optional[Path] = None) -> Workflow:
"""Save a deep copy of ``wf`` under a fresh id and a "… (copy)" name, so it can
be run in parallel with (or diverge from) the original. Node/edge ids are kept
— they're only unique *within* a workflow, and each run gets its own id."""
import copy as _copy
dup = Workflow(
id=_mint_id("wf"),
name=f"{wf.name} ({tr_copy_suffix()})",
nodes=[_copy.deepcopy(n) for n in wf.nodes],
edges=[_copy.deepcopy(e) for e in wf.edges],
is_template=False,
)
save_workflow(dup, directory)
return dup
def tr_copy_suffix() -> str:
"""Localised 'copy' suffix — kept tiny + import-safe (no hard i18n dependency
at module import time)."""
try:
from ..i18n import tr
return tr("co4e.copy_suffix")
except Exception: # noqa: BLE001
return "copy"
def delete_workflow(wf_id: str, directory: Optional[Path] = None) -> None:
"""Xoá file luồng theo id; không có thì bỏ qua."""
directory = directory or WORKFLOWS_DIR
path = directory / f"{wf_id}.json"
if path.exists():
try:
path.unlink()
except OSError:
pass
# ---- custom-agent store --------------------------------------------------
def agents_dir() -> Path:
"""Thư mục chứa file agent tự tạo."""
return AGENTS_DIR
def list_custom_agents(directory: Optional[Path] = None) -> List[CustomAgent]:
"""Liệt kê mọi agent tự tạo; thư mục chưa có thì trả list rỗng."""
directory = directory or AGENTS_DIR
if not directory.exists():
return []
out: List[CustomAgent] = []
for path in sorted(directory.glob("*.json")):
try:
out.append(agent_from_dict(json.loads(path.read_text(encoding="utf-8"))))
except (OSError, json.JSONDecodeError, TypeError, ValueError):
continue
return out
def save_custom_agent(agent: CustomAgent, directory: Optional[Path] = None) -> Path:
"""Ghi một agent tự tạo ra ``<id>.json``."""
directory = directory or AGENTS_DIR
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{agent.id}.json"
AtomicJsonFile(path).write(agent_to_dict(agent))
return path
def delete_custom_agent(agent_id: str, directory: Optional[Path] = None) -> None:
"""Xoá file agent tự tạo theo id; không có thì bỏ qua."""
directory = directory or AGENTS_DIR
path = directory / f"{agent_id}.json"
if path.exists():
try:
path.unlink()
except OSError:
pass
# ---- wave computation ----------------------------------------------------
def compute_waves(nodes: List[Node], edges: List[Edge]) -> Dict[str, int]:
"""node id -> wave index (longest path from a root). Ignores edges that
reference unknown nodes; cycles are broken defensively (a node never waits
on itself transitively past the node count)."""
ids = {n.id for n in nodes}
preds: Dict[str, List[str]] = {n.id: [] for n in nodes}
for e in edges:
if e.source in ids and e.target in ids:
preds[e.target].append(e.source)
wave: Dict[str, int] = {}
limit = len(nodes) + 1
def depth(nid: str, seen: frozenset) -> int:
"""Độ sâu của một node = lớp chạy của nó.
Có nhớ kết quả và chặn theo ``limit``: đồ thị có vòng sẽ khiến đệ quy chạy
mãi, nên gặp node đã thấy trong nhánh hiện tại thì dừng.
"""
if nid in wave:
return wave[nid]
if nid in seen or len(seen) > limit:
return 0
ps = preds.get(nid, [])
w = 0 if not ps else 1 + max(depth(p, seen | {nid}) for p in ps)
wave[nid] = w
return w
for n in nodes:
depth(n.id, frozenset())
return wave
def connected_component_count(nodes: List[Node], edges: List[Edge]) -> int:
"""Weakly-connected component count — >1 warns a flow is accidentally split."""
ids = {n.id for n in nodes}
parent = {n.id: n.id for n in nodes}
def find(x):
"""Tìm gốc của một phần tử, kèm nén đường đi (union-find)."""
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
"""Gộp hai tập hợp lại làm một (union-find)."""
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
for e in edges:
if e.source in ids and e.target in ids:
union(e.source, e.target)
return len({find(n.id) for n in nodes})
# ---- run-stage compilation ----------------------------------------------
@dataclass
class RunStage:
"""Một chặng chạy: ứng với một node, hoặc một nhánh song song / bước gộp của nó."""
id: str # node id, or "<node>__p<i>" / "<node>__pjoin"
node_id: str # which canvas node this stage maps back onto
wave: int
prompt: str
model: str = ""
scope: Optional[List[str]] = None
self_verify: bool = False
max_verify_rounds: int = 1
PLAN_MODE_PREAMBLE = (
"PLAN MODE — do NOT execute anything or change any files. Produce a concise, "
"numbered plan of what you WOULD do for this step, then stop.\n\n")
def build_skills_block(skills: List[str], skill_map: Dict[str, str]) -> str:
"""Ghép nội dung các skill được chọn thành một khối chèn vào prompt."""
parts = []
for name in skills or []:
content = (skill_map.get(name) or "").strip()
if content:
parts.append(f'--- Skill "{name}" ---\n{content}')
if not parts:
return ""
return ("\nThis agent carries the following attached skills (reusable "
"instruction packs):\n" + "\n\n".join(parts) + "\n")
def _shared_prompt_parts(step: Step, skill_map: Dict[str, str], extra_context: str) -> str:
"""Phần prompt dùng chung cho cả ba loại chặng: chỉ dẫn của bước, khối skill,
và ngữ cảnh thêm từ các bước trước.
"""
parts = []
if step.instructions.strip():
parts.append(step.instructions.strip())
# Extra step/agent context (free-text background the user added in config) —
# injected so the agent has more information to carry out its task.
if getattr(step, "context", "").strip():
parts.append("Additional context:\n" + step.context.strip())
block = build_skills_block(step.skills, skill_map)
if block:
parts.append(block)
if extra_context:
parts.append(extra_context)
return "\n\n".join(parts)
def build_step_prompt(step: Step, skill_map: Dict[str, str], extra_context: str = "") -> str:
"""Prompt cho một bước chạy tuần tự bình thường."""
head = f'You are the {step.role} agent for the workflow step "{step.label}".'
body = _shared_prompt_parts(step, skill_map, extra_context)
return f"{head}\n{body}".strip()
def build_subagent_prompt(step: Step, sub: SubAgent, peers: List[str],
skill_map: Dict[str, str], extra_context: str = "") -> str:
"""Prompt cho một sub-agent chạy song song.
Nói rõ nó đang chạy CÙNG LÚC với những ai và phải ở trong phạm vi của mình —
không có câu đó, các sub-agent hay làm chồng việc của nhau.
"""
peer_txt = ", ".join(p for p in peers if p) or "peers"
head = (f'You are the "{sub.agent}" agent working concurrently (in parallel with '
f'{peer_txt}) on the workflow step "{step.label}". Stay within your own scope.')
parts = [head]
if sub.instructions.strip():
parts.append(sub.instructions.strip())
shared = _shared_prompt_parts(step, skill_map, extra_context)
if shared:
parts.append(shared)
return "\n\n".join(parts).strip()
def build_join_prompt(step: Step, skill_map: Dict[str, str], extra_context: str = "") -> str:
"""Prompt cho bước gộp: hợp nhất đầu ra của các sub-agent thành một kết quả."""
head = (f'You are the coordinator for the parallel step "{step.label}". Consolidate the '
f"outputs of the sub-agents (provided above as prior outputs) into one coherent result.")
body = _shared_prompt_parts(step, skill_map, extra_context)
return f"{head}\n{body}".strip()
def compile_run_stages(nodes: List[Node], edges: List[Edge],
extra_context: Dict[str, str] = None,
plan_mode: bool = False,
skill_map: Dict[str, str] = None) -> List[RunStage]:
"""Compile canvas nodes into ordered RunStages. ``extra_context`` maps a
node id to text (upstream outputs) to append to that node's prompt."""
extra_context = extra_context or {}
skill_map = skill_map or {}
waves = compute_waves(nodes, edges)
stages: List[RunStage] = []
def finalize(prompt: str, preset: str) -> tuple:
"""Chốt prompt của một chặng: áp phạm vi theo preset, và thêm lời mở đầu chế
độ lập kế hoạch nếu đang chạy ở chế độ đó.
"""
scope = PRESET_SCOPES.get(preset)
if plan_mode:
prompt = PLAN_MODE_PREAMBLE + prompt
scope = PRESET_SCOPES["read-only"]
return prompt, scope
for node in nodes:
step = node.data
w = waves.get(node.id, 0)
ctx = extra_context.get(node.id, "")
if step.is_parallel and step.sub_agents:
peers = [s.agent for s in step.sub_agents]
for i, sub in enumerate(step.sub_agents):
prompt = build_subagent_prompt(step, sub, peers, skill_map, ctx)
prompt, scope = finalize(prompt, step.permission_preset)
stages.append(RunStage(
id=f"{node.id}__p{i}", node_id=node.id, wave=w, prompt=prompt,
model=step.model, scope=scope))
if step.instructions.strip():
prompt, scope = finalize(build_join_prompt(step, skill_map), step.permission_preset)
stages.append(RunStage(
id=f"{node.id}__pjoin", node_id=node.id, wave=w + 1, prompt=prompt,
model=step.model, scope=scope,
self_verify=step.self_verify, max_verify_rounds=max(1, step.max_verify_rounds)))
else:
prompt, scope = finalize(build_step_prompt(step, skill_map, ctx), step.permission_preset)
stages.append(RunStage(
id=node.id, node_id=node.id, wave=w, prompt=prompt, model=step.model, scope=scope,
self_verify=step.self_verify, max_verify_rounds=max(1, step.max_verify_rounds)))
stages.sort(key=lambda s: s.wave)
return stages
def stage_node_id(stage_id: str) -> str:
"""Map a stage id back onto its canvas node ('<node>__p2' -> '<node>')."""
for sep in ("__pjoin", "__p"):
if sep in stage_id:
return stage_id.split(sep, 1)[0]
return stage_id