Skip to content

Commit 9fcda7e

Browse files
committed
fix(agent): attribute nested tool activity to its source
Resolve Agent logs, details and usage statistics from the executed source workflow and binding while preserving the outer workflow run relationship.
1 parent cf1717f commit 9fcda7e

3 files changed

Lines changed: 399 additions & 42 deletions

File tree

‎api/services/agent/observability_service.py‎

Lines changed: 92 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,19 @@
1414

1515
from configs import dify_config
1616
from core.app.entities.app_invoke_entities import InvokeFrom
17+
from core.workflow.node_execution_process_data import WORKFLOW_AGENT_BINDING_ID_KEY
1718
from graphon.enums import WorkflowNodeExecutionStatus
1819
from libs.helper import convert_datetime_to_date, escape_like_pattern, to_timestamp
1920
from models.agent import WorkflowAgentNodeBinding
2021
from models.enums import CreatorUserRole, FeedbackFromSource, FeedbackRating, MessageStatus
2122
from models.model import App, Conversation, Message, MessageFeedback
22-
from models.workflow import WorkflowNodeExecutionModel, WorkflowRun, WorkflowType
23+
from models.workflow import (
24+
Workflow,
25+
WorkflowNodeExecutionModel,
26+
WorkflowNodeExecutionTriggeredFrom,
27+
WorkflowRun,
28+
WorkflowType,
29+
)
2330

2431

2532
@dataclass(frozen=True)
@@ -326,17 +333,15 @@ def _list_workflow_conversation_logs(
326333
and_(
327334
WorkflowAgentNodeBinding.tenant_id == app.tenant_id,
328335
WorkflowAgentNodeBinding.agent_id == agent_id,
329-
WorkflowAgentNodeBinding.app_id == WorkflowRun.app_id,
330-
WorkflowAgentNodeBinding.workflow_id == WorkflowRun.workflow_id,
331-
WorkflowAgentNodeBinding.workflow_version == WorkflowRun.version,
336+
self._workflow_node_binding_condition(),
332337
),
333338
)
334-
.join(workflow_app, workflow_app.id == WorkflowAgentNodeBinding.app_id)
339+
.join(
340+
workflow_app,
341+
and_(workflow_app.id == WorkflowAgentNodeBinding.app_id, workflow_app.tenant_id == app.tenant_id),
342+
)
335343
.where(
336344
WorkflowNodeExecutionModel.tenant_id == app.tenant_id,
337-
WorkflowNodeExecutionModel.app_id == WorkflowAgentNodeBinding.app_id,
338-
WorkflowNodeExecutionModel.workflow_id == WorkflowAgentNodeBinding.workflow_id,
339-
WorkflowNodeExecutionModel.node_id == WorkflowAgentNodeBinding.node_id,
340345
)
341346
)
342347
stmt = self._apply_workflow_node_filters(stmt, params=params, workflow_app=workflow_app)
@@ -443,18 +448,16 @@ def _list_workflow_messages(
443448
and_(
444449
WorkflowAgentNodeBinding.tenant_id == app.tenant_id,
445450
WorkflowAgentNodeBinding.agent_id == agent_id,
446-
WorkflowAgentNodeBinding.app_id == WorkflowRun.app_id,
447-
WorkflowAgentNodeBinding.workflow_id == WorkflowRun.workflow_id,
448-
WorkflowAgentNodeBinding.workflow_version == WorkflowRun.version,
451+
self._workflow_node_binding_condition(),
449452
),
450453
)
451-
.join(workflow_app, workflow_app.id == WorkflowAgentNodeBinding.app_id)
454+
.join(
455+
workflow_app,
456+
and_(workflow_app.id == WorkflowAgentNodeBinding.app_id, workflow_app.tenant_id == app.tenant_id),
457+
)
452458
.where(
453459
WorkflowNodeExecutionModel.id == conversation_id,
454460
WorkflowNodeExecutionModel.tenant_id == app.tenant_id,
455-
WorkflowNodeExecutionModel.app_id == WorkflowAgentNodeBinding.app_id,
456-
WorkflowNodeExecutionModel.workflow_id == WorkflowAgentNodeBinding.workflow_id,
457-
WorkflowNodeExecutionModel.node_id == WorkflowAgentNodeBinding.node_id,
458461
)
459462
)
460463
stmt = self._apply_workflow_node_filters(stmt, params=params, workflow_app=workflow_app)
@@ -466,6 +469,65 @@ def _list_workflow_messages(
466469
)
467470
return [self.serialize_workflow_node_message(execution) for execution in executions]
468471

472+
def _workflow_node_binding_condition(self, *, statistics: bool = False) -> sa.ColumnElement[bool]:
473+
"""Resolve source ownership independently of a Tool's outer lifecycle run."""
474+
node = aliased(WorkflowNodeExecutionModel, name="wne") if statistics else WorkflowNodeExecutionModel
475+
binding = aliased(WorkflowAgentNodeBinding, name="wanb") if statistics else WorkflowAgentNodeBinding
476+
run = aliased(WorkflowRun, name="wr") if statistics else WorkflowRun
477+
process_data_json = func.nullif(node.process_data, "")
478+
if self._session.get_bind().dialect.name == "sqlite":
479+
binding_id = func.json_extract(process_data_json, f"$.{WORKFLOW_AGENT_BINDING_ID_KEY}")
480+
else:
481+
binding_id = sa.cast(process_data_json, sa.JSON)[WORKFLOW_AGENT_BINDING_ID_KEY].as_string()
482+
483+
node_table = "wne" if statistics else WorkflowNodeExecutionModel.__tablename__
484+
source_version = (
485+
select(Workflow.version)
486+
.where(
487+
Workflow.id == sa.literal_column(f"{node_table}.workflow_id"),
488+
Workflow.app_id == sa.literal_column(f"{node_table}.app_id"),
489+
Workflow.tenant_id == sa.literal_column(f"{node_table}.tenant_id"),
490+
)
491+
.scalar_subquery()
492+
)
493+
old_binding = aliased(WorkflowAgentNodeBinding, name="old_binding")
494+
single_binding_version = (
495+
select(func.min(old_binding.workflow_version))
496+
.where(
497+
old_binding.tenant_id == sa.literal_column(f"{node_table}.tenant_id"),
498+
old_binding.app_id == sa.literal_column(f"{node_table}.app_id"),
499+
old_binding.workflow_id == sa.literal_column(f"{node_table}.workflow_id"),
500+
old_binding.node_id == sa.literal_column(f"{node_table}.node_id"),
501+
)
502+
.having(func.count() == 1)
503+
.scalar_subquery()
504+
)
505+
is_tool = node.triggered_from == WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
506+
# Old Tool rows may outlive their source Workflow. A sole source binding
507+
# is still unambiguous; never choose among versions using the caller run.
508+
old_version = sa.case((is_tool, func.coalesce(source_version, single_binding_version)), else_=run.version)
509+
return and_(
510+
node.tenant_id == run.tenant_id,
511+
binding.tenant_id == node.tenant_id,
512+
binding.app_id == node.app_id,
513+
binding.workflow_id == node.workflow_id,
514+
binding.node_id == node.node_id,
515+
or_(is_tool, and_(node.app_id == run.app_id, node.workflow_id == run.workflow_id)),
516+
or_(
517+
sa.cast(binding.id, sa.String) == binding_id,
518+
and_(binding_id.is_(None), binding.workflow_version == old_version),
519+
),
520+
)
521+
522+
def _statistics_workflow_node_binding_join_sql(self) -> str:
523+
# Reuse the log/detail owner predicate in both raw-SQL statistics queries.
524+
return str(
525+
self._workflow_node_binding_condition(statistics=True).compile(
526+
dialect=self._session.get_bind().dialect,
527+
compile_kwargs={"literal_binds": True},
528+
)
529+
)
530+
469531
def _list_workflow_sources(self, *, app: App, agent_id: str) -> list[dict[str, Any]]:
470532
workflow_app = aliased(App)
471533
stmt = (
@@ -947,6 +1009,7 @@ def _load_workflow_daily_statistics(
9471009
("agent_log", "agent_backend", "usage", "completion_tokens"), "BIGINT"
9481010
)
9491011
binding_filters = self._statistics_workflow_binding_filters_sql(source_filter)
1012+
binding_join = self._statistics_workflow_node_binding_join_sql()
9501013
run_date_filters = ""
9511014
args: dict[str, Any] = {
9521015
"tz": params.timezone,
@@ -981,17 +1044,14 @@ def _load_workflow_daily_statistics(
9811044
COALESCE(SUM(COALESCE(wne.elapsed_time, 0)), 0) AS latency,
9821045
COALESCE(SUM(COALESCE({completion_tokens}, 0)), 0) AS answer_tokens
9831046
FROM workflow_runs wr
1047+
JOIN workflow_node_executions wne
1048+
ON wne.workflow_run_id = wr.id
9841049
JOIN workflow_agent_node_bindings wanb
985-
ON wanb.tenant_id = :tenant_id
1050+
ON {binding_join}
9861051
AND wanb.agent_id = :agent_id
987-
AND wanb.app_id = wr.app_id
988-
AND wanb.workflow_id = wr.workflow_id
989-
AND wanb.workflow_version = wr.version
9901052
{binding_filters}
991-
JOIN workflow_node_executions wne
992-
ON wne.workflow_run_id = wr.id
993-
AND wne.node_id = wanb.node_id
994-
WHERE wr.type != :chat_workflow_type{run_date_filters}
1053+
JOIN apps source_app ON source_app.id = wanb.app_id AND source_app.tenant_id = wr.tenant_id
1054+
WHERE wr.tenant_id = :tenant_id AND wr.type != :chat_workflow_type{run_date_filters}
9951055
GROUP BY wr.id, wr.created_by_role, wr.created_by, wr.created_at
9961056
)
9971057
SELECT
@@ -1085,24 +1145,23 @@ def _statistics_workflow_binding_filters_sql(source_filter: AgentSourceFilter) -
10851145
workflow_binding_filters.append("wanb.node_id = :node_id")
10861146
return f"AND {' AND '.join(workflow_binding_filters)}" if workflow_binding_filters else ""
10871147

1088-
@classmethod
1089-
def _statistics_workflow_message_scope_sql(cls, source_filter: AgentSourceFilter) -> str:
1090-
binding_filters = cls._statistics_workflow_binding_filters_sql(source_filter)
1148+
def _statistics_workflow_message_scope_sql(self, source_filter: AgentSourceFilter) -> str:
1149+
binding_filters = self._statistics_workflow_binding_filters_sql(source_filter)
1150+
binding_join = self._statistics_workflow_node_binding_join_sql()
10911151
return f"""m.workflow_run_id IS NOT NULL
10921152
AND EXISTS (
10931153
SELECT 1
10941154
FROM workflow_runs wr
1155+
JOIN workflow_node_executions wne
1156+
ON wne.workflow_run_id = wr.id
10951157
JOIN workflow_agent_node_bindings wanb
1096-
ON wanb.tenant_id = :tenant_id
1158+
ON {binding_join}
10971159
AND wanb.agent_id = :agent_id
1098-
AND wanb.app_id = wr.app_id
1099-
AND wanb.workflow_id = wr.workflow_id
1100-
AND wanb.workflow_version = wr.version
11011160
{binding_filters}
1102-
JOIN workflow_node_executions wne
1103-
ON wne.workflow_run_id = wr.id
1104-
AND wne.node_id = wanb.node_id
1161+
JOIN apps source_app ON source_app.id = wanb.app_id AND source_app.tenant_id = wr.tenant_id
11051162
WHERE wr.id = m.workflow_run_id
1163+
AND wr.app_id = m.app_id
1164+
AND wr.tenant_id = :tenant_id
11061165
AND wr.type = :chat_workflow_type
11071166
)"""
11081167

‎api/tests/unit_tests/services/agent/test_agent_observability_service.py‎

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121
from models.model import App, AppMode, Conversation, Message, MessageFeedback
2222
from models.workflow import (
23+
Workflow,
2324
WorkflowExecutionStatus,
2425
WorkflowNodeExecutionModel,
2526
WorkflowNodeExecutionTriggeredFrom,
@@ -272,15 +273,6 @@ def test_statistics_workflow_app_source_covers_all_versions_and_nodes() -> None:
272273
assert "wanb.node_id = :node_id" not in scope_sql
273274

274275

275-
def test_statistics_workflow_chat_context_only_uses_chat_runs() -> None:
276-
source_filter = AgentObservabilityService.resolve_source_filter("workflow:app-2")
277-
278-
scope_sql = AgentObservabilityService._statistics_workflow_message_scope_sql(source_filter)
279-
280-
assert "wr.id = m.workflow_run_id" in scope_sql
281-
assert "wr.type = :chat_workflow_type" in scope_sql
282-
283-
284276
def test_workflow_metadata_numeric_sql_supports_postgresql_and_mysql(monkeypatch: pytest.MonkeyPatch) -> None:
285277
apply_config_overrides(monkeypatch, DB_TYPE="postgresql")
286278

@@ -489,6 +481,97 @@ def test_list_workflow_logs_uses_node_executions_without_messages(sqlite_session
489481
assert rows[0]["source"]["app_name"] == "Marketing Department"
490482

491483

484+
@pytest.mark.parametrize("source_identity", ["persisted-binding", "source-version", "unique-legacy-binding"])
485+
def test_nested_agent_logs_and_messages_keep_source_identity(sqlite_session: Session, source_identity: str) -> None:
486+
source_app = _app(app_id="workflow-app-1", name="Source workflow", mode=AppMode.WORKFLOW)
487+
source = Workflow(
488+
id="workflow-1",
489+
tenant_id="tenant-1",
490+
app_id=source_app.id,
491+
type=WorkflowType.WORKFLOW,
492+
version="v1",
493+
graph="{}",
494+
_features="{}",
495+
created_by="account-1",
496+
)
497+
run = _workflow_run()
498+
run.app_id, run.workflow_id, run.version = "caller-app", "caller-workflow", "caller-version"
499+
execution = _node_execution()
500+
execution.triggered_from = WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
501+
binding = _workflow_binding()
502+
if source_identity == "persisted-binding":
503+
execution.process_data = json.dumps({"workflow_agent_binding_id": binding.id})
504+
unused_binding = _workflow_binding(binding_id="unused-binding")
505+
unused_binding.workflow_version = "v2"
506+
sqlite_session.add_all([source_app, run, execution, binding])
507+
if source_identity != "unique-legacy-binding":
508+
sqlite_session.add_all([source, unused_binding])
509+
sqlite_session.commit()
510+
service = AgentObservabilityService(sqlite_session)
511+
params = AgentLogQueryParams(sources=("workflow:workflow-app-1",))
512+
513+
logs = service.list_logs(app=_app(app_id="agent-app"), agent_id="agent-1", params=params)
514+
messages = service.list_log_messages(
515+
app=_app(app_id="agent-app"), agent_id="agent-1", conversation_id=execution.id, params=params
516+
)
517+
518+
assert logs["total"] == messages["total"] == 1
519+
assert logs["data"][0]["id"] == messages["data"][0]["id"] == execution.id
520+
assert logs["data"][0]["source"]["id"] == "workflow:workflow-app-1:workflow-1:v1:node-1"
521+
assert messages["data"][0]["total_tokens"] == 454_064
522+
for source_filter in ("workflow:caller-app", "workflow:workflow-app-1:workflow-1:v2:node-1"):
523+
params = AgentLogQueryParams(sources=(source_filter,))
524+
assert service.list_logs(app=_app(app_id="agent-app"), agent_id="agent-1", params=params)["total"] == 0
525+
assert (
526+
service.list_log_messages(
527+
app=_app(app_id="agent-app"), agent_id="agent-1", conversation_id=execution.id, params=params
528+
)["total"]
529+
== 0
530+
)
531+
532+
if source_identity == "unique-legacy-binding":
533+
# Without a captured binding or source Workflow, another Agent/version
534+
# makes attribution ambiguous, even when filtering for only agent-1.
535+
unused_binding.agent_id = "another-agent"
536+
sqlite_session.add(unused_binding)
537+
sqlite_session.commit()
538+
params = AgentLogQueryParams(sources=("workflow",))
539+
assert service.list_logs(app=_app(app_id="agent-app"), agent_id="agent-1", params=params)["total"] == 0
540+
assert (
541+
service.list_log_messages(
542+
app=_app(app_id="agent-app"), agent_id="agent-1", conversation_id=execution.id, params=params
543+
)["total"]
544+
== 0
545+
)
546+
547+
548+
@pytest.mark.parametrize("mismatch", ["run-tenant", "source-tenant", "binding-id", "unrelated-run"])
549+
def test_workflow_agent_observability_rejects_mismatched_ownership(sqlite_session: Session, mismatch: str) -> None:
550+
source_app = _app(app_id="workflow-app-1", mode=AppMode.WORKFLOW)
551+
run, execution, binding = _workflow_run(), _node_execution(), _workflow_binding()
552+
execution.process_data = json.dumps({"workflow_agent_binding_id": binding.id})
553+
if mismatch == "run-tenant":
554+
run.tenant_id = "other-tenant"
555+
elif mismatch == "source-tenant":
556+
source_app.tenant_id = "other-tenant"
557+
elif mismatch == "binding-id":
558+
execution.process_data = json.dumps({"workflow_agent_binding_id": "missing-binding"})
559+
else:
560+
run.app_id, run.workflow_id = "unrelated-app", "unrelated-workflow"
561+
sqlite_session.add_all([source_app, run, execution, binding])
562+
sqlite_session.commit()
563+
service = AgentObservabilityService(sqlite_session)
564+
params = AgentLogQueryParams(sources=("workflow",))
565+
566+
assert service.list_logs(app=_app(app_id="agent-app"), agent_id="agent-1", params=params)["total"] == 0
567+
assert (
568+
service.list_log_messages(
569+
app=_app(app_id="agent-app"), agent_id="agent-1", conversation_id=execution.id, params=params
570+
)["total"]
571+
== 0
572+
)
573+
574+
492575
def test_list_workflow_messages_uses_node_execution_identity(
493576
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
494577
) -> None:

0 commit comments

Comments
 (0)