Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
1b2e48e
vector link docker added
yaredtsy Mar 17, 2026
ac10ee4
schema cleanup
yaredtsy Mar 17, 2026
e5ba264
playground endpoints improved
yaredtsy Mar 17, 2026
9d8546d
playground api connected
yaredtsy Mar 17, 2026
477a634
docker fixed
yaredtsy Mar 17, 2026
42ffb0e
basic structure added
yaredtsy Mar 18, 2026
31623aa
loc added
yaredtsy Mar 18, 2026
1e91b8e
basic structure extended
yaredtsy Mar 18, 2026
57d49d7
save
yaredtsy Mar 18, 2026
0100282
conversation state improved
yaredtsy Mar 18, 2026
4c9c183
more improvement
yaredtsy Mar 18, 2026
f2671b8
improvement
yaredtsy Mar 18, 2026
f4b7ab7
improve
yaredtsy Mar 18, 2026
1d61726
improved
yaredtsy Mar 19, 2026
78f3ddd
fix
yaredtsy Mar 19, 2026
ae0718c
storage imporved
yaredtsy Mar 19, 2026
583f424
test added
yaredtsy Mar 19, 2026
039070b
workflows improved
yaredtsy Mar 19, 2026
428f712
test imporved
yaredtsy Mar 19, 2026
4cf8ed8
repo and schema added for conversation
yaredtsy Mar 19, 2026
1d65d3b
split in to smaller files
yaredtsy Mar 19, 2026
9aaa7b6
conversation logic started
yaredtsy Mar 19, 2026
0adb1dc
conversation logic improved
yaredtsy Mar 19, 2026
c4f4cb0
conversation added
yaredtsy Mar 19, 2026
8bb7fd1
frontend setuped
yaredtsy Mar 19, 2026
84b8d90
simple conversation setuped
yaredtsy Mar 19, 2026
cf2df58
route fix
yaredtsy Mar 19, 2026
ae058aa
fixed
yaredtsy Mar 19, 2026
3759267
basic chat fixed
yaredtsy Mar 21, 2026
3ac3fad
improving message ui
yaredtsy Mar 21, 2026
c91d4ef
connection and ui imporvemnt
yaredtsy Mar 21, 2026
7f5b367
ui cleanup and task ui added
yaredtsy Mar 21, 2026
cb33778
agent service added
yaredtsy Mar 22, 2026
71af21a
workflow service added
yaredtsy Mar 22, 2026
479e7e4
refactor
yaredtsy Mar 22, 2026
1422101
ui improve
yaredtsy Mar 22, 2026
e2a44ae
workflow checkpoint
yaredtsy Mar 22, 2026
8a62af6
refactor
yaredtsy Mar 23, 2026
6f57d0c
workflows improved
yaredtsy Mar 24, 2026
520600f
task sub task test fixed
yaredtsy Mar 25, 2026
a36a732
frontend improved
yaredtsy Mar 25, 2026
addd067
documentation generator fixed
yaredtsy Mar 25, 2026
f3cb2dd
remove litellm
yaredtsy Mar 25, 2026
9221156
walkthrough started
yaredtsy Mar 26, 2026
70b6bf4
engine added
yaredtsy Mar 26, 2026
b931197
basic walkthrough guider added
yaredtsy Mar 26, 2026
12d601a
popover fixed
yaredtsy Mar 26, 2026
a0df0fa
code highlighter added
yaredtsy Mar 26, 2026
2488045
bottom bar added improved
yaredtsy Mar 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
5 changes: 5 additions & 0 deletions src/backend/app/agent/chat/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Chat completion and message-adjacent agent types (shared API + runner)."""

from app.agent.chat.completion_params import ChatCompletionParams

__all__ = ["ChatCompletionParams"]
36 changes: 36 additions & 0 deletions src/backend/app/agent/chat/completion_params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""LLM call parameters for chat turns (REST and executor share this model)."""

from __future__ import annotations

from pydantic import BaseModel, ConfigDict, Field


class ChatCompletionParams(BaseModel):
"""
Overrides for a single assistant generation.
Omitted fields fall back to `AgentConfig` / provider defaults.
"""

model_config = ConfigDict(extra="ignore")

provider: str | None = Field(
default=None,
description="Registered provider name, e.g. openai",
)
model: str | None = Field(
default=None,
description="Provider model id, e.g. gpt-4o-mini",
)
temperature: float | None = Field(default=None, ge=0.0, le=2.0)
max_tokens: int | None = Field(default=None, ge=1, le=128_000)
top_p: float | None = Field(default=None, ge=0.0, le=1.0)
frequency_penalty: float | None = Field(default=None, ge=-2.0, le=2.0)
presence_penalty: float | None = Field(default=None, ge=-2.0, le=2.0)
stop: list[str] | None = Field(default=None, max_length=8)

def provider_create_kwargs(self) -> dict:
"""Keyword args for `LLMFactory.create` / ChatOpenAI (excluding provider+model)."""
return self.model_dump(
exclude={"provider", "model"},
exclude_none=True,
)
33 changes: 33 additions & 0 deletions src/backend/app/agent/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import os
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class AgentConfig(BaseSettings):
# LLM defaults
default_provider: str = "openai"
default_model: str = "gpt-4o"
openai_api_key: str = ""

# Agent behavior
max_iterations: int = Field(default=10, ge=1, le=1000)
max_total_tokens: int = Field(default=128_000, ge=1024)

# VectorLink
vectorlink_url: str = "http://localhost:8080"

model_config = SettingsConfigDict(
env_prefix="AGENT_",
env_file=os.environ.get("ENV_FILE", ".env"),
env_file_encoding="utf-8",
extra="ignore",
)


@lru_cache()
def get_agent_settings() -> AgentConfig:
return AgentConfig()


settings = get_agent_settings()
Empty file.
57 changes: 57 additions & 0 deletions src/backend/app/agent/context/context_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from app.agent.context.graph_traversal import GraphTraversal
from app.agent.context.vectorlink_client import VectorLinkClient
from app.agent.context.token_tracker import TokenTracker


class ContextBUilder:
"""Assemble prompt context from multiple sources within a token budget."""

def __init__(self,
graph: GraphTraversal,
vectorlink: VectorLinkClient,
budget: TokenTracker):
self.graph = graph
self.vectorlink = vectorlink
self.budget = budget

async def build_context(
self,
*,
node_id: str | None = None,
query: str | None = None,
include_code: bool = False,
traversal_direction: str = "down",
traversal_depth: int = 2,
vector_top_k: int = 5,
):
"""
Build context by:
1. Graph traversal from node_id (if provided)
2. Vector search for query (if provided)
3. Merge, deduplicate, rank by relevance
4. Truncate to fit token budget
5. Optionally attach code content
"""
context_items = []

# Step 1: Graph traversal
if node_id:
if traversal_direction == "down":
nodes = await self.graph.traverse_down(node_id, traversal_depth)
else:
nodes = await self.graph.traverse_up(node_id, traversal_depth)
context_items.extend(nodes)

# Step 2: Vector search
if query:
results = await self.vectorlink.search(
db="...", # from ProjectUoW
query=query,
top_k=vector_top_k,
)
context_items.extend(results)

# Step 3-5: Deduplicate, budget-check, enrich
...

return context_items
189 changes: 189 additions & 0 deletions src/backend/app/agent/context/graph_traversal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
from app.db.context import ProjectUoW
from app.db.async_terminus_client import WOQLQuery as WQ
from app.core.builder.tree_builder import TreeBuilder
from app.core.services.code_element_service import CodeElementService


class GraphTraversal:
"""Walk and shape project graph data for workflow execution."""

EDGE_FIELDS = (
"folder_children",
"file_children",
"class_children",
"function_children",
"call_children",
"code_element_group",
"call_group",
"structure_group",
)
EDGE_PATTERN = "(" + "|".join(EDGE_FIELDS) + ")"

def __init__(self, uow: ProjectUoW):
self.uow = uow
self.repos = uow.get_project_repos()
self.code_service = CodeElementService(uow)

def _extract_children(self, doc: dict) -> list[str]:
children: list[str] = []
for edge in self.EDGE_FIELDS:
raw = doc.get(edge)
if raw is None:
continue
if isinstance(raw, (list, set, tuple)):
children.extend([str(item) for item in raw if item])
else:
children.append(str(raw))
return list(set(children))

@staticmethod
def _normalize_type_name(type_name: str | None) -> str:
if not type_name:
return ""
return type_name.replace("Schema", "")

def _normalize_doc(self, doc: dict) -> dict:
normalized = dict(doc)
normalized["id"] = normalized.get("@id")
normalized["type"] = normalized.get("@type")
normalized["children"] = self._extract_children(doc)
return normalized

def _dedupe_nodes(self, nodes: list[dict]) -> list[dict]:
unique: dict[str, dict] = {}
for node in nodes:
node_id = node.get("id") or node.get("@id")
if not node_id:
continue
unique[node_id] = node
return list(unique.values())

# TODO: Make imporve type filtering
async def traverse_down(
self,
node_id: str | None,
max_depth: int = 5,
node_types: list[str] | None = None,
) -> list[dict]:
"""
Get all descendants from node_id and include the start node.
Returns full node docs with normalized `id`, `type`, and `children`.
"""
if not node_id:
all_nodes, _ = await self.repos.project_repo.get_children(
exclude_types=[])
normalized_nodes = [
self._normalize_doc(node.model_dump())
for node in all_nodes
]
return self._dedupe_nodes(normalized_nodes)

pattern = "+" if max_depth <= 0 else f"{{1,{max_depth}}}"
query = (
WQ()
.eq("v:start", node_id)
.path("v:start", f"{self.EDGE_PATTERN}{pattern}", "v:child")
.read_document("v:child", "v:child_doc")
)

allowed_types = None
if node_types:
allowed_types = {
self._normalize_type_name(node_type) for node_type in node_types
}

nodes: list[dict] = []
if self.repos.client:
result = await self.repos.client.query(query)
for row in result.get("bindings", []):
doc = row.get("child_doc", {})
if allowed_types:
doc_type = self._normalize_type_name(doc.get("@type"))
if doc_type not in allowed_types:
continue
nodes.append(self._normalize_doc(doc))

start_result = await self.repos.client.get_document(node_id)
if start_result:
nodes.append(self._normalize_doc(start_result))

return self._dedupe_nodes(nodes)

async def traverse_up(
self,
node_id: str,
max_depth: int = 5,
) -> list[dict]:
"""
Get all ancestors from node_id and include the start node.
Returns full node docs with normalized `id`, `type`, and `children`.
"""
pattern = "+" if max_depth <= 0 else f"{{1,{max_depth}}}"
query = (
WQ()
.eq("v:start", node_id)
.path("v:start", f"<{self.EDGE_PATTERN}{pattern}", "v:parent")
.read_document("v:parent", "v:parent_doc")
)

nodes: list[dict] = []
if self.repos.client:
result = await self.repos.client.query(query)
for row in result.get("bindings", []):
doc = row.get("parent_doc", {})
nodes.append(self._normalize_doc(doc))

start_result = await self.repos.client.get_document(node_id)
if start_result:
nodes.append(self._normalize_doc(start_result))

return self._dedupe_nodes(nodes)

async def build_tree(self, node_id: str | None, node_types: list[str] | None = None, max_depth: int = 5):
"""Build nested tree nodes for subtree rooted at `node_id`."""
nodes = await self.traverse_down(node_id=node_id, node_types=node_types, max_depth=max_depth)
tree = TreeBuilder(base_nodes=nodes).build()
return tree

async def get_siblings(self, node_id: str) -> list[dict]:
"""Get nodes at the same level (same parent)."""
parents = await self.traverse_up(node_id, max_depth=1)
if not parents:
return []

parent_id = parents[0]["id"] if parents[0]["id"] != node_id else (
parents[1]["id"] if len(parents) > 1 else None
)
if not parent_id:
return []

children = await self.traverse_down(parent_id, max_depth=1)
return [c for c in children if c["id"] not in {node_id, parent_id}]

async def get_node_with_code(self, node_id: str) -> dict:
"""Fetch node and hydrate code via CodeElementService.get_code."""
if not self.repos.client:
return {}

doc = await self.repos.client.get_document(node_id)
if not doc:
return {}

try:
code_payload = await self.code_service.get_code(node_id)
if code_payload and code_payload.get("code"):
doc["code_content_data"] = code_payload["code"]
except Exception:
# Keep workflow robust for nodes that don't have code ranges.
doc["code_content_data"] = ""
return doc

async def get_code_content(self, node_id: str) -> str:
"""Fetch only code content for a node without hydrating full doc."""
try:
code_payload = await self.code_service.get_code(node_id)
if code_payload and code_payload.get("code"):
return code_payload["code"]
except Exception:
return ""
return ""
38 changes: 38 additions & 0 deletions src/backend/app/agent/context/token_tracker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from langchain_core.callbacks import BaseCallbackHandler
from pydantic import BaseModel
from typing import Optional


class TokenUsage(BaseModel):
"""Accumulated token usage for a single run."""
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
model: Optional[str] = None


class TokenTracker(BaseCallbackHandler):
"""LangChain callback handler that accumulates token usage across calls."""

def __init__(self, max_total_tokens: int = 128_000):
self.max_total_tokens = max_total_tokens
self.usage = TokenUsage()

def on_llm_end(self, response, **kwargs):
"""Called after each LLM invocation — accumulates usage."""
if hasattr(response, "llm_output") and response.llm_output:
usage = response.llm_output.get("token_usage", {})
self.usage.prompt_tokens += usage.get("prompt_tokens", 0)
self.usage.completion_tokens += usage.get("completion_tokens", 0)
self.usage.total_tokens += usage.get("total_tokens", 0)

@property
def remaining(self) -> int:
return self.max_total_tokens - self.usage.total_tokens

@property
def over_budget(self) -> bool:
return self.usage.total_tokens >= self.max_total_tokens

def get_usage(self) -> TokenUsage:
return self.usage.model_copy()
Loading