feat: improve workflow agent tool interation

This commit is contained in:
Alex committed 2026-07-09 10:22:44 +01:00
1 parent 51dc49f0d8
commit 6dd5cf472a
12 files changed
+420 -214

No files matched your search

+7 -5
View File
@@ -57,18 +57,20 @@ full flag set.
Run the Celery worker in a separate terminal (if needed):
```bash
celery -A application.app.celery worker -l INFO -Q docsgpt,parsing
celery -A application.app.celery worker -l INFO
```
On macOS, prefer the solo pool for Celery:
```bash
python -m celery -A application.app.celery worker -l INFO --pool=solo -Q docsgpt,parsing
python -m celery -A application.app.celery worker -l INFO --pool=solo
```
The `parsing` queue serves document parsing (the `read_document` tool / workflow
native-file parse); without it those calls hang `DOCUMENT_PARSE_TIMEOUT` then
error. A dedicated `-Q parsing` worker can be GPU-enabled for heavier parsers.
A bare worker (no `-Q`) consumes every configured queue, so one worker does the
whole job — app tasks and document parsing (the `read_document` tool / workflow
native-file parse) alike. Use `-Q` only to split load: run the main worker with
`-Q docsgpt` and a dedicated (e.g. GPU-enabled) parser worker with `-Q parsing`
for heavy OCR.
### Frontend
+34 -1
View File
@@ -210,6 +210,10 @@ class ToolExecutor:
self.tool_allowlist: set = {str(x) for x in tool_allowlist} if tool_allowlist else set()
self.tool_calls: List[Dict] = []
self._loaded_tools: Dict[str, object] = {}
# Explicit tool-id scope (workflow agent nodes): when set (even empty),
# get_tools() resolves EXACTLY these ids — builtin synthetic ids and
# user_tools rows alike — with no defaults mixed in. None = unscoped.
self.allowed_tool_ids: Optional[List[str]] = None
self.conversation_id: Optional[str] = None
# Set by the workflow engine for agent nodes so run-scoped tools
# (artifact_generator / code_executor) address artifacts by the
@@ -232,7 +236,9 @@ class ToolExecutor:
If *client_tools* have been set on this executor, they are
automatically merged into the returned dict.
"""
if self.user_api_key:
if self.allowed_tool_ids is not None:
tools = self._get_tools_by_ids(self.allowed_tool_ids)
elif self.user_api_key:
tools = self._get_tools_by_api_key(self.user_api_key)
else:
tools = self._get_user_tools(self.user or "local")
@@ -250,6 +256,29 @@ class ToolExecutor:
"""
return {str(tool["name"]) for tool in self.get_tools().values() if isinstance(tool, dict) and tool.get("name")}
def _get_tools_by_ids(self, tool_ids: List[str]) -> Dict[str, Dict]:
"""Resolve an explicit tool-id scope — exactly these ids, no defaults.
Used by workflow agent nodes: the node's configured tools (builtin
synthetic ids like Artifact/Code Executor/Read Document, or the user's
``user_tools`` rows) are the node's WHOLE toolset. An unresolvable id
is dropped with a warning rather than failing the node.
"""
if not tool_ids:
return {}
with db_readonly() as conn:
tools_repo = UserToolsRepository(conn)
tools: List[Dict] = []
for tid in tool_ids:
row = resolve_tool_by_id(tid, self.user, user_tools_repo=tools_repo)
if row is None:
logger.warning("tool id %s did not resolve; dropped from scoped toolset", tid)
continue
if self.headless and is_headless_excluded_tool(row.get("name")):
continue
tools.append(row)
return {str(tool["id"]): tool for tool in tools}
def _get_tools_by_api_key(self, api_key: str) -> Dict[str, Dict]:
"""Resolve an agent's toolset — exactly ``agents.tools``, no defaults."""
# Per-operation session: the answer pipeline spans a long-lived
@@ -265,6 +294,10 @@ class ToolExecutor:
row = resolve_tool_by_id(tid, owner, user_tools_repo=tools_repo)
if row is None:
continue
# Workflow-only builtins (read_document) never resolve for a
# chat/scheduled agent — nodes get them via the scoped-id path.
if row.get("workflow_only"):
continue
# Headless runs (scheduled / webhook) drop chat-only tools
# like ``scheduler`` so a fire-time LLM can't chain schedules.
if self.headless and is_headless_excluded_tool(row.get("name")):
+46 -7
View File
@@ -401,6 +401,26 @@ def merge_patch(target: Any, patch: Any) -> Any:
return result
def _apply_spec_append(spec: Dict[str, Any], spec_append: Dict[str, Any]) -> Dict[str, Any]:
"""Concatenate items onto the spec's top-level lists (blocks/sections/slides/sheets).
RFC 7386 merge-patch replaces arrays wholesale, so "add a section" via
spec_patch silently wipes the existing ones unless the model resends the
full array. spec_append is the safe additive path: each key must name a
list (absent counts as empty) and its items are appended in order.
Returns {"spec": merged} or {"error": message}.
"""
result = copy.deepcopy(spec)
for key, items in spec_append.items():
if not isinstance(items, list):
return {"error": f"spec_append[{key!r}] must be a list of items to append."}
current = result.get(key, [])
if not isinstance(current, list):
return {"error": f"spec_append target {key!r} is not a list in the current spec."}
result[key] = current + copy.deepcopy(items)
return {"spec": result}
class ArtifactGeneratorTool(Tool):
"""Artifact
Create, edit, and version documents - slides, docs, sheets, PDF, HTML.
@@ -450,8 +470,10 @@ class ArtifactGeneratorTool(Tool):
{
"name": "edit_artifact",
"description": (
"Apply a JSON merge-patch (RFC 7386) to the current spec, re-render, and append a "
"new version. Preferred for small, targeted changes."
"Apply a JSON merge-patch (RFC 7386) and/or append items to the current spec, "
"re-render, and append a new version. Preferred for small, targeted changes. "
"CAUTION: an array in spec_patch REPLACES the whole existing array — to add "
"slides/sections/blocks/sheets while keeping the existing ones, use spec_append."
),
"active": True,
"parameters": {
@@ -464,10 +486,17 @@ class ArtifactGeneratorTool(Tool):
},
"spec_patch": {
"type": "object",
"description": "RFC 7386 merge-patch; null values delete keys.",
"description": "RFC 7386 merge-patch; null values delete keys; arrays "
"replace the existing array wholesale.",
},
"spec_append": {
"type": "object",
"description": "Additive edit: {key: [items]} appends items to the "
'spec\'s top-level list, e.g. {"blocks": [{"type": "heading", "text": '
'"Risks"}]} keeps existing blocks and adds these after them.',
},
},
"required": ["id", "spec_patch"],
"required": ["id"],
},
},
{
@@ -559,14 +588,24 @@ class ArtifactGeneratorTool(Tool):
return {"status": "ok", **ref}
def _edit(self, **kwargs: Any) -> Dict[str, Any]:
"""Merge-patch the current spec, re-render, and append a version."""
"""Merge-patch and/or list-append the current spec, re-render, and append a version."""
spec_patch = kwargs.get("spec_patch")
if not isinstance(spec_patch, dict):
spec_append = kwargs.get("spec_append")
if spec_patch is None and spec_append is None:
return {"status": "error", "error": "edit_artifact needs spec_patch and/or spec_append."}
if spec_patch is not None and not isinstance(spec_patch, dict):
return {"status": "error", "error": "spec_patch must be a JSON object (merge-patch)."}
if spec_append is not None and not isinstance(spec_append, dict):
return {"status": "error", "error": "spec_append must be a JSON object of {key: [items]}."}
loaded = self._load_current(kwargs.get("id"))
if loaded.get("error"):
return {"status": "error", "error": loaded["error"]}
new_spec = merge_patch(loaded["spec"], spec_patch)
new_spec = merge_patch(loaded["spec"], spec_patch) if spec_patch else dict(loaded["spec"] or {})
if spec_append:
appended = _apply_spec_append(new_spec, spec_append)
if "error" in appended:
return {"status": "error", "error": appended["error"]}
new_spec = appended["spec"]
return self._reversion(
loaded["artifact_id"], loaded["kind"], new_spec, "edit_artifact", loaded.get("title")
)
+10 -33
View File
@@ -1,6 +1,6 @@
"""Workflow Node Agents - defines specialized agents for workflow nodes."""
from typing import Any, Dict, List, Optional, Type
from typing import Dict, List, Optional, Type
from application.agents.agentic_agent import AgenticAgent
from application.agents.base import BaseAgent
@@ -9,34 +9,6 @@ from application.agents.research_agent import ResearchAgent
from application.agents.workflows.schemas import AgentType
class ToolFilterMixin:
"""Mixin that filters fetched tools to only those specified in tool_ids."""
_allowed_tool_ids: List[str]
def _get_user_tools(self, user: str = "local") -> Dict[str, Dict[str, Any]]:
all_tools = super()._get_user_tools(user)
if not self._allowed_tool_ids:
return {}
filtered_tools = {
tool_id: tool
for tool_id, tool in all_tools.items()
if str(tool.get("_id", "")) in self._allowed_tool_ids
}
return filtered_tools
def _get_tools(self, api_key: str = None) -> Dict[str, Dict[str, Any]]:
all_tools = super()._get_tools(api_key)
if not self._allowed_tool_ids:
return {}
filtered_tools = {
tool_id: tool
for tool_id, tool in all_tools.items()
if str(tool.get("_id", "")) in self._allowed_tool_ids
}
return filtered_tools
class _WorkflowNodeMixin:
"""Common __init__ for all workflow node agents."""
@@ -56,18 +28,23 @@ class _WorkflowNodeMixin:
api_key=api_key,
**kwargs,
)
self._allowed_tool_ids = tool_ids or []
# Scope the executor to exactly the node's configured tools. Agents
# fetch their toolset via ``tool_executor.get_tools()``, so the scope
# must live on the executor — it resolves builtin synthetic ids
# (Artifact / Code Executor / Read Document) and ``user_tools`` rows
# alike, and an empty list means the node's LLM gets no tools.
self.tool_executor.allowed_tool_ids = [str(t) for t in (tool_ids or [])]
class WorkflowNodeClassicAgent(ToolFilterMixin, _WorkflowNodeMixin, ClassicAgent):
class WorkflowNodeClassicAgent(_WorkflowNodeMixin, ClassicAgent):
pass
class WorkflowNodeAgenticAgent(ToolFilterMixin, _WorkflowNodeMixin, AgenticAgent):
class WorkflowNodeAgenticAgent(_WorkflowNodeMixin, AgenticAgent):
pass
class WorkflowNodeResearchAgent(ToolFilterMixin, _WorkflowNodeMixin, ResearchAgent):
class WorkflowNodeResearchAgent(_WorkflowNodeMixin, ResearchAgent):
pass
+10
View File
@@ -1,3 +1,5 @@
from kombu import Queue
from application.core.settings import settings
# Pydantic loads .env into ``settings`` but does not inject values into
@@ -27,6 +29,14 @@ task_routes = {
"application.api.user.tasks.parse_document": {"queue": settings.DOCUMENT_PARSE_QUEUE},
}
# Declare every queue so a bare ``celery worker`` (no -Q) consumes ALL of them —
# the default worker does the whole job, parsing included. Operators who want
# heavy OCR isolated run one worker with ``-Q docsgpt`` and another with
# ``-Q parsing``. (dict.fromkeys dedupes if DOCUMENT_PARSE_QUEUE == "docsgpt".)
task_queues = tuple(
Queue(name) for name in dict.fromkeys(["docsgpt", settings.DOCUMENT_PARSE_QUEUE])
)
beat_scheduler = "redbeat.RedBeatScheduler"
redbeat_redis_url = broker_url
redbeat_key_prefix = "redbeat:docsgpt:"
@@ -76,6 +76,7 @@ import {
validateCodeJsonSchema,
} from './codeNodeConfig';
import MobileBlocker from './components/MobileBlocker';
import { buildSimpleCel, parseSimpleCel } from './simpleCel';
import NodeDocumentsControl from './components/NodeDocumentsControl';
import PromptTextArea, {
extractUpstreamVariables,
@@ -181,79 +182,6 @@ function canReachEnd(
.some((e) => canReachEnd(e.target, edges, nodeIds, endIds, visited));
}
function parseSimpleCel(expression: string): {
variable: string;
operator: string;
value: string;
} {
const trimmedExpression = expression.trim();
let match = trimmedExpression.match(
/^(\w+)\.(contains|startsWith)\(["'](.*)["']\)$/,
);
if (match) return { variable: match[1], operator: match[2], value: match[3] };
match = trimmedExpression.match(/^(\w+)\.(contains|startsWith)\((.*)\)$/);
if (match) {
const rawValue = match[3].trim();
const unquotedValue = rawValue.replace(/^["'](.*)["']$/, '$1');
return {
variable: match[1],
operator: match[2],
value: unquotedValue,
};
}
match = trimmedExpression.match(/^(contains|startsWith)\(["'](.*)["']\)$/);
if (match) return { variable: '', operator: match[1], value: match[2] };
match = trimmedExpression.match(/^(contains|startsWith)\((.*)\)$/);
if (match) {
const rawValue = match[2].trim();
const unquotedValue = rawValue.replace(/^["'](.*)["']$/, '$1');
return { variable: '', operator: match[1], value: unquotedValue };
}
match = trimmedExpression.match(/^(\w+)\s*(==|!=|>=|<=|>|<)\s*["'](.*)["']$/);
if (match) return { variable: match[1], operator: match[2], value: match[3] };
match = trimmedExpression.match(/^(==|!=|>=|<=|>|<)\s*["'](.*)["']$/);
if (match) return { variable: '', operator: match[1], value: match[2] };
match = trimmedExpression.match(/^(\w+)\s*(==|!=|>=|<=|>|<)\s*(.*)$/);
if (match) return { variable: match[1], operator: match[2], value: match[3] };
match = trimmedExpression.match(/^(==|!=|>=|<=|>|<)\s*(.*)$/);
if (match) return { variable: '', operator: match[1], value: match[2] };
return { variable: '', operator: '==', value: '' };
}
function buildSimpleCel(
variable: string,
operator: string,
value: string,
): string {
const trimmedValue = value.trim();
const isNumeric = trimmedValue !== '' && !isNaN(Number(trimmedValue));
const isBool = trimmedValue === 'true' || trimmedValue === 'false';
const literalValue =
isNumeric || isBool ? trimmedValue : JSON.stringify(value);
const stringValue = JSON.stringify(value);
if (operator === 'contains') {
return variable
? `${variable}.contains(${stringValue})`
: `contains(${stringValue})`;
}
if (operator === 'startsWith') {
return variable
? `${variable}.startsWith(${stringValue})`
: `startsWith(${stringValue})`;
}
if (!variable) return `${operator} ${literalValue}`;
return `${variable} ${operator} ${literalValue}`;
}
function normalizeConditionCases(cases: ConditionCase[]): ConditionCase[] {
const usedHandles = new Set<string>();
let nextIndex = 0;
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { buildSimpleCel, parseSimpleCel } from './simpleCel';
describe('parseSimpleCel', () => {
it('parses a plain variable comparison', () => {
expect(parseSimpleCel('status == "open"')).toEqual({
variable: 'status',
operator: '==',
value: 'open',
});
});
it('parses a dotted variable path the backend CEL accepts', () => {
// Regression: Simple mode blocked `audit.high_risk_accounts >= 3` at
// Preview ("must specify a variable") while createWorkflow accepted it.
expect(parseSimpleCel('audit.high_risk_accounts >= 3')).toEqual({
variable: 'audit.high_risk_accounts',
operator: '>=',
value: '3',
});
});
it('parses dotted paths with contains/startsWith', () => {
expect(parseSimpleCel('audit.summary.contains("risk")')).toEqual({
variable: 'audit.summary',
operator: 'contains',
value: 'risk',
});
expect(parseSimpleCel('doc.name.startsWith("Q3")')).toEqual({
variable: 'doc.name',
operator: 'startsWith',
value: 'Q3',
});
});
it('parses quoted comparisons on dotted paths', () => {
expect(parseSimpleCel('meta.region == "EMEA"')).toEqual({
variable: 'meta.region',
operator: '==',
value: 'EMEA',
});
});
it('keeps variable-less forms working', () => {
expect(parseSimpleCel('>= 4')).toEqual({
variable: '',
operator: '>=',
value: '4',
});
expect(parseSimpleCel('contains("x")')).toEqual({
variable: '',
operator: 'contains',
value: 'x',
});
});
it('falls back to an empty template for unparseable input', () => {
expect(parseSimpleCel('!!not cel!!')).toEqual({
variable: '',
operator: '==',
value: '',
});
});
});
describe('buildSimpleCel round-trip', () => {
it('round-trips a dotted numeric comparison', () => {
const built = buildSimpleCel('audit.high_risk_accounts', '>=', '3');
expect(built).toBe('audit.high_risk_accounts >= 3');
expect(parseSimpleCel(built)).toEqual({
variable: 'audit.high_risk_accounts',
operator: '>=',
value: '3',
});
});
it('round-trips a dotted contains', () => {
const built = buildSimpleCel('audit.summary', 'contains', 'risk');
expect(parseSimpleCel(built)).toEqual({
variable: 'audit.summary',
operator: 'contains',
value: 'risk',
});
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Parse/build the Simple-mode condition expressions. The variable side accepts
* dotted paths (`audit.high_risk_accounts`) because the engine's CEL evaluator
* resolves them — Simple mode rejecting what the backend happily runs left
* saved workflows blocked at Preview with "must specify a variable".
*/
export function parseSimpleCel(expression: string): {
variable: string;
operator: string;
value: string;
} {
const trimmedExpression = expression.trim();
let match = trimmedExpression.match(
/^([\w.]+)\.(contains|startsWith)\(["'](.*)["']\)$/,
);
if (match) return { variable: match[1], operator: match[2], value: match[3] };
match = trimmedExpression.match(/^([\w.]+)\.(contains|startsWith)\((.*)\)$/);
if (match) {
const rawValue = match[3].trim();
const unquotedValue = rawValue.replace(/^["'](.*)["']$/, '$1');
return {
variable: match[1],
operator: match[2],
value: unquotedValue,
};
}
match = trimmedExpression.match(/^(contains|startsWith)\(["'](.*)["']\)$/);
if (match) return { variable: '', operator: match[1], value: match[2] };
match = trimmedExpression.match(/^(contains|startsWith)\((.*)\)$/);
if (match) {
const rawValue = match[2].trim();
const unquotedValue = rawValue.replace(/^["'](.*)["']$/, '$1');
return { variable: '', operator: match[1], value: unquotedValue };
}
match = trimmedExpression.match(
/^([\w.]+)\s*(==|!=|>=|<=|>|<)\s*["'](.*)["']$/,
);
if (match) return { variable: match[1], operator: match[2], value: match[3] };
match = trimmedExpression.match(/^(==|!=|>=|<=|>|<)\s*["'](.*)["']$/);
if (match) return { variable: '', operator: match[1], value: match[2] };
match = trimmedExpression.match(/^([\w.]+)\s*(==|!=|>=|<=|>|<)\s*(.*)$/);
if (match) return { variable: match[1], operator: match[2], value: match[3] };
match = trimmedExpression.match(/^(==|!=|>=|<=|>|<)\s*(.*)$/);
if (match) return { variable: '', operator: match[1], value: match[2] };
return { variable: '', operator: '==', value: '' };
}
export function buildSimpleCel(
variable: string,
operator: string,
value: string,
): string {
const trimmedValue = value.trim();
const isNumeric = trimmedValue !== '' && !isNaN(Number(trimmedValue));
const isBool = trimmedValue === 'true' || trimmedValue === 'false';
const literalValue =
isNumeric || isBool ? trimmedValue : JSON.stringify(value);
const stringValue = JSON.stringify(value);
if (operator === 'contains') {
return variable
? `${variable}.contains(${stringValue})`
: `contains(${stringValue})`;
}
if (operator === 'startsWith') {
return variable
? `${variable}.startsWith(${stringValue})`
: `startsWith(${stringValue})`;
}
if (!variable) return `${operator} ${literalValue}`;
return `${variable} ${operator} ${literalValue}`;
}
+48 -95
View File
@@ -1,79 +1,28 @@
from contextlib import contextmanager
from unittest.mock import MagicMock
import pytest
@pytest.mark.unit
class TestToolFilterMixin:
@pytest.fixture()
def _no_db(monkeypatch):
"""Scoped-id resolution opens a read conn; synthetic ids never use it."""
def test_get_user_tools_filters_by_allowed_ids(self):
from application.agents.workflows.node_agent import ToolFilterMixin
@contextmanager
def fake_readonly():
yield MagicMock()
class FakeBase:
def _get_user_tools(self, user="local"):
return {
"t1": {"_id": "id1", "name": "tool1"},
"t2": {"_id": "id2", "name": "tool2"},
"t3": {"_id": "id3", "name": "tool3"},
}
monkeypatch.setattr("application.agents.tool_executor.db_readonly", fake_readonly)
class TestClass(ToolFilterMixin, FakeBase):
pass
obj = TestClass()
obj._allowed_tool_ids = ["id1", "id3"]
result = obj._get_user_tools("user1")
assert "t1" in result
assert "t3" in result
assert "t2" not in result
class _FakeExecutorBase:
"""Stands in for BaseAgent: provides the tool_executor the mixin scopes."""
def test_get_user_tools_returns_empty_when_no_allowed(self):
from application.agents.workflows.node_agent import ToolFilterMixin
def __init__(self, *args, **kwargs):
from application.agents.tool_executor import ToolExecutor
class FakeBase:
def _get_user_tools(self, user="local"):
return {"t1": {"_id": "id1"}}
class TestClass(ToolFilterMixin, FakeBase):
pass
obj = TestClass()
obj._allowed_tool_ids = []
result = obj._get_user_tools()
assert result == {}
def test_get_tools_filters_by_allowed_ids(self):
from application.agents.workflows.node_agent import ToolFilterMixin
class FakeBase:
def _get_tools(self, api_key=None):
return {
"t1": {"_id": "id1"},
"t2": {"_id": "id2"},
}
class TestClass(ToolFilterMixin, FakeBase):
pass
obj = TestClass()
obj._allowed_tool_ids = ["id2"]
result = obj._get_tools("key")
assert "t2" in result
assert "t1" not in result
def test_get_tools_returns_empty_when_no_allowed(self):
from application.agents.workflows.node_agent import ToolFilterMixin
class FakeBase:
def _get_tools(self, api_key=None):
return {"t1": {"_id": "id1"}}
class TestClass(ToolFilterMixin, FakeBase):
pass
obj = TestClass()
obj._allowed_tool_ids = []
result = obj._get_tools()
assert result == {}
self.tool_executor = ToolExecutor()
@pytest.mark.unit
@@ -92,49 +41,53 @@ class TestWorkflowNodeAgentFactory:
)
# =====================================================================
# Coverage gap tests (lines 52-59: _WorkflowNodeMixin.__init__)
# =====================================================================
@pytest.mark.unit
class TestWorkflowNodeMixinInit:
"""The mixin scopes the EXECUTOR: agents fetch tools via tool_executor.get_tools(),
so per-node tool filtering must live there (the old agent-method mixin was dead code)."""
def test_mixin_init_sets_allowed_tool_ids(self):
"""Cover lines 52-59: _WorkflowNodeMixin.__init__ stores tool_ids."""
def _mixed(self, **kwargs):
from application.agents.workflows.node_agent import _WorkflowNodeMixin
class FakeBase:
def __init__(self, *args, **kwargs):
pass
class TestMixin(_WorkflowNodeMixin, FakeBase):
class TestMixin(_WorkflowNodeMixin, _FakeExecutorBase):
pass
obj = TestMixin(
return TestMixin(
endpoint="http://example.com",
llm_name="openai",
model_id="gpt-4",
api_key="key",
tool_ids=["tool1", "tool2"],
**kwargs,
)
assert obj._allowed_tool_ids == ["tool1", "tool2"]
def test_mixin_init_defaults_empty_tool_ids(self):
"""Cover: _WorkflowNodeMixin defaults to empty list."""
from application.agents.workflows.node_agent import _WorkflowNodeMixin
def test_mixin_scopes_executor_to_tool_ids(self):
obj = self._mixed(tool_ids=["tool1", "tool2"])
assert obj.tool_executor.allowed_tool_ids == ["tool1", "tool2"]
class FakeBase:
def __init__(self, *args, **kwargs):
pass
def test_mixin_defaults_to_empty_scope_not_unscoped(self):
# No tool_ids means the node gets NO tools — never the user's full set.
obj = self._mixed()
assert obj.tool_executor.allowed_tool_ids == []
class TestMixin(_WorkflowNodeMixin, FakeBase):
pass
def test_empty_scope_yields_no_tools(self):
obj = self._mixed()
assert obj.tool_executor.get_tools() == {}
obj = TestMixin(
endpoint="http://example.com",
llm_name="openai",
model_id="gpt-4",
api_key="key",
def test_scoped_executor_resolves_builtin_synthetic_ids(self, _no_db):
"""A node whose Tools picker selected a builtin (Artifact / Read Document)
must get that tool at runtime — the P0 this design replaced."""
from application.agents.default_tools import default_tool_id
obj = self._mixed(
tool_ids=[
default_tool_id("artifact_generator"),
default_tool_id("read_document"),
]
)
assert obj._allowed_tool_ids == []
tools = obj.tool_executor.get_tools()
names = {t["name"] for t in tools.values()}
assert names == {"artifact_generator", "read_document"}
def test_scoped_executor_drops_unresolvable_ids(self, _no_db):
obj = self._mixed(tool_ids=["not-a-real-tool-id"])
assert obj.tool_executor.get_tools() == {}
+27
View File
@@ -66,6 +66,33 @@ class TestToolExecutorGetTools:
assert str(tool["id"]) in tools
assert tools[str(tool["id"])]["id"] == tool["id"]
def test_api_key_agent_never_resolves_workflow_only_builtins(self, pg_conn, monkeypatch):
"""read_document is workflow-only: a chat/scheduled agent carrying its
builtin id must not get it; workflow nodes get it via allowed_tool_ids."""
from application.agents.default_tools import default_tool_id
from application.storage.db.repositories.agents import AgentsRepository
read_doc_id = default_tool_id("read_document")
artifact_id = default_tool_id("artifact_generator")
AgentsRepository(pg_conn).create(
user_id="alice",
name="a",
status="active",
key="wf_only_key",
tools=[read_doc_id, artifact_id],
)
self._patch_conn(monkeypatch, pg_conn)
chat_tools = ToolExecutor(user_api_key="wf_only_key", user="alice").get_tools()
chat_names = {t["name"] for t in chat_tools.values()}
assert "artifact_generator" in chat_names
assert "read_document" not in chat_names
scoped = ToolExecutor(user="alice")
scoped.allowed_tool_ids = [read_doc_id]
node_names = {t["name"] for t in scoped.get_tools().values()}
assert node_names == {"read_document"}
def test_agentless_chat_synthesizes_defaults(self, pg_conn, monkeypatch):
from application.agents.default_tools import loaded_default_tools
from application.storage.db.repositories.user_tools import UserToolsRepository
@@ -405,3 +405,60 @@ def test_create_and_rewrite_metadata_embed_spec_synopsis():
rewrite_spec = actions["rewrite_artifact"]["parameters"]["properties"]["spec"]
assert _SPEC_SYNOPSIS in create_spec["description"]
assert _SPEC_SYNOPSIS in rewrite_spec["description"]
# ---------------------------------------------------------------------------
# spec_append: additive edits that don't clobber arrays
# ---------------------------------------------------------------------------
def test_spec_append_preserves_existing_items():
from application.agents.tools.artifact_generator import _apply_spec_append
spec = {"title": "Brief", "blocks": [{"type": "heading", "text": "Overview"}]}
out = _apply_spec_append(spec, {"blocks": [{"type": "heading", "text": "Risks"}]})
assert "error" not in out
assert [b["text"] for b in out["spec"]["blocks"]] == ["Overview", "Risks"]
# The input spec is not mutated.
assert len(spec["blocks"]) == 1
def test_spec_append_creates_missing_list():
from application.agents.tools.artifact_generator import _apply_spec_append
out = _apply_spec_append({"title": "x"}, {"blocks": [{"type": "paragraph", "text": "p"}]})
assert out["spec"]["blocks"] == [{"type": "paragraph", "text": "p"}]
def test_spec_append_rejects_non_list_values_and_targets():
from application.agents.tools.artifact_generator import _apply_spec_append
assert "error" in _apply_spec_append({}, {"blocks": "not-a-list"})
assert "error" in _apply_spec_append({"title": "t"}, {"title": ["x"]})
def test_edit_with_only_spec_append_appends_to_loaded_spec(monkeypatch):
tool = _tool()
loaded_spec = {"blocks": [{"type": "heading", "text": "Overview"}]}
monkeypatch.setattr(
tool,
"_load_current",
lambda raw_id: {"artifact_id": "a-1", "kind": "html", "spec": loaded_spec, "title": "T"},
)
captured = {}
def fake_reversion(artifact_id, kind, spec, action, title=None):
captured["spec"] = spec
return {"status": "ok", "artifact_id": artifact_id}
monkeypatch.setattr(tool, "_reversion", fake_reversion)
result = tool._edit(id="A1", spec_append={"blocks": [{"type": "paragraph", "text": "Risks…"}]})
assert result["status"] == "ok"
assert [b["text"] for b in captured["spec"]["blocks"]] == ["Overview", "Risks…"]
def test_edit_requires_patch_or_append():
err = _tool()._edit(id="A1")
assert err["status"] == "error"
assert "spec_patch and/or spec_append" in err["error"]
+13
View File
@@ -791,3 +791,16 @@ class TestIngestPoisonEvent:
)
assert published[0][0][2]["operation"] == "reingest"
@pytest.mark.unit
def test_bare_worker_consumes_app_and_parsing_queues():
"""task_queues declares every queue, so a worker started without -Q serves
both app tasks and document parsing — a -Q-less dev worker must never
silently strand attachment uploads or parse_document tasks."""
import application.celeryconfig as celeryconfig
from application.core.settings import settings
names = {queue.name for queue in celeryconfig.task_queues}
assert "docsgpt" in names
assert settings.DOCUMENT_PARSE_QUEUE in names