activegraph 1.0.2.post1__tar.gz → 1.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. {activegraph-1.0.2.post1 → activegraph-1.0.3}/PKG-INFO +1 -1
  2. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/__init__.py +3 -2
  3. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/behaviors/decorators.py +54 -0
  4. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/core/graph.py +23 -3
  5. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/llm/anthropic.py +21 -1
  6. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/llm/recorded.py +17 -1
  7. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/llm/types.py +12 -0
  8. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/observability/logging.py +4 -0
  9. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/runtime.py +136 -39
  10. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/tools/graph_query.py +1 -1
  11. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph.egg-info/PKG-INFO +1 -1
  12. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph.egg-info/SOURCES.txt +2 -0
  13. {activegraph-1.0.2.post1 → activegraph-1.0.3}/pyproject.toml +1 -1
  14. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_graph.py +61 -0
  15. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_behavior.py +103 -0
  16. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_observability_logging.py +2 -0
  17. activegraph-1.0.3/tests/test_v1_0_3_behavior_failed_ux.py +262 -0
  18. activegraph-1.0.3/tests/test_v1_0_3_tool_multiturn.py +371 -0
  19. {activegraph-1.0.2.post1 → activegraph-1.0.3}/README.md +0 -0
  20. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/__main__.py +0 -0
  21. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/behaviors/__init__.py +0 -0
  22. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/behaviors/base.py +0 -0
  23. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/cli/__init__.py +0 -0
  24. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/cli/main.py +0 -0
  25. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/cli/quickstart.py +0 -0
  26. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/core/__init__.py +0 -0
  27. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/core/clock.py +0 -0
  28. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/core/event.py +0 -0
  29. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/core/ids.py +0 -0
  30. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/core/patch.py +0 -0
  31. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/core/view.py +0 -0
  32. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/errors.py +0 -0
  33. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/frame.py +0 -0
  34. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/llm/__init__.py +0 -0
  35. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/llm/cache.py +0 -0
  36. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/llm/errors.py +0 -0
  37. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/llm/openai.py +0 -0
  38. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/llm/parsing.py +0 -0
  39. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/llm/prompt.py +0 -0
  40. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/llm/provider.py +0 -0
  41. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/observability/__init__.py +0 -0
  42. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/observability/metrics.py +0 -0
  43. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/observability/migration.py +0 -0
  44. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/observability/prometheus.py +0 -0
  45. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/observability/status.py +0 -0
  46. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/packs/__init__.py +0 -0
  47. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/packs/diligence/__init__.py +0 -0
  48. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/packs/diligence/behaviors.py +0 -0
  49. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/packs/diligence/fixtures/__init__.py +0 -0
  50. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/packs/diligence/fixtures/companies.py +0 -0
  51. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/packs/diligence/object_types.py +0 -0
  52. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/packs/diligence/prompts/document_researcher.md +0 -0
  53. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/packs/diligence/prompts/memo_synthesizer.md +0 -0
  54. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/packs/diligence/prompts/question_generator.md +0 -0
  55. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/packs/diligence/prompts/risk_identifier.md +0 -0
  56. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/packs/diligence/settings.py +0 -0
  57. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/packs/diligence/tools.py +0 -0
  58. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/packs/loader.py +0 -0
  59. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/packs/scaffold.py +0 -0
  60. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/policy.py +0 -0
  61. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/__init__.py +0 -0
  62. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/_live.py +0 -0
  63. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/behavior_graph.py +0 -0
  64. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/budget.py +0 -0
  65. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/config_errors.py +0 -0
  66. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/diff.py +0 -0
  67. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/errors.py +0 -0
  68. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/exec_errors.py +0 -0
  69. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/patterns.py +0 -0
  70. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/queue.py +0 -0
  71. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/registration_errors.py +0 -0
  72. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/registry.py +0 -0
  73. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/scheduler.py +0 -0
  74. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/runtime/view_builder.py +0 -0
  75. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/store/__init__.py +0 -0
  76. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/store/base.py +0 -0
  77. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/store/conformance.py +0 -0
  78. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/store/errors.py +0 -0
  79. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/store/memory.py +0 -0
  80. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/store/postgres.py +0 -0
  81. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/store/serde.py +0 -0
  82. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/store/sqlite.py +0 -0
  83. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/store/url.py +0 -0
  84. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/tools/__init__.py +0 -0
  85. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/tools/base.py +0 -0
  86. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/tools/cache.py +0 -0
  87. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/tools/context.py +0 -0
  88. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/tools/decorators.py +0 -0
  89. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/tools/errors.py +0 -0
  90. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/tools/recorded.py +0 -0
  91. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/tools/web_fetch.py +0 -0
  92. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/trace/__init__.py +0 -0
  93. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/trace/causal.py +0 -0
  94. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph/trace/printer.py +0 -0
  95. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph.egg-info/dependency_links.txt +0 -0
  96. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph.egg-info/entry_points.txt +0 -0
  97. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph.egg-info/requires.txt +0 -0
  98. {activegraph-1.0.2.post1 → activegraph-1.0.3}/activegraph.egg-info/top_level.txt +0 -0
  99. {activegraph-1.0.2.post1 → activegraph-1.0.3}/setup.cfg +0 -0
  100. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_activate_after.py +0 -0
  101. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_causal_cross_tool.py +0 -0
  102. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_cli.py +0 -0
  103. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_clock.py +0 -0
  104. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_diff.py +0 -0
  105. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_diligence_pack.py +0 -0
  106. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_diligence_with_tools.py +0 -0
  107. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_doc_links.py +0 -0
  108. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_doc_site_reachable.py +0 -0
  109. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_errors_format.py +0 -0
  110. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_event.py +0 -0
  111. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_fork.py +0 -0
  112. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_ids.py +0 -0
  113. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_anthropic.py +0 -0
  114. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_budget.py +0 -0
  115. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_causal.py +0 -0
  116. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_claim_extraction.py +0 -0
  117. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_default_model.py +0 -0
  118. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_determinism.py +0 -0
  119. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_failure.py +0 -0
  120. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_openai.py +0 -0
  121. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_prompt.py +0 -0
  122. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_provider_fixtures.py +0 -0
  123. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_replay.py +0 -0
  124. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_tool_loop.py +0 -0
  125. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_trace.py +0 -0
  126. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_trace_snapshot.py +0 -0
  127. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_llm_types.py +0 -0
  128. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_migration.py +0 -0
  129. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_observability_metrics.py +0 -0
  130. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_operate_example.py +0 -0
  131. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_pack_scaffold.py +0 -0
  132. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_packs.py +0 -0
  133. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_patch.py +0 -0
  134. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_pattern_matcher.py +0 -0
  135. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_pattern_parser.py +0 -0
  136. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_pattern_subscriptions.py +0 -0
  137. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_persistence.py +0 -0
  138. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_postgres_store.py +0 -0
  139. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_quickstart.py +0 -0
  140. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_quickstart_snapshot.py +0 -0
  141. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_replay.py +0 -0
  142. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_replay_trace_snapshot.py +0 -0
  143. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_requeue_unfired.py +0 -0
  144. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_resume_example.py +0 -0
  145. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_runtime.py +0 -0
  146. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_runtime_status.py +0 -0
  147. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_serde.py +0 -0
  148. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_store_conformance.py +0 -0
  149. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_store_url.py +0 -0
  150. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_tool_replay.py +0 -0
  151. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_tool_trace_snapshot.py +0 -0
  152. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_tools.py +0 -0
  153. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_trace.py +0 -0
  154. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_tutorial_snippets.py +0 -0
  155. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_v1_0_1_patches.py +0 -0
  156. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_version_sync.py +0 -0
  157. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_view.py +0 -0
  158. {activegraph-1.0.2.post1 → activegraph-1.0.3}/tests/test_wheel_completeness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activegraph
3
- Version: 1.0.2.post1
3
+ Version: 1.0.3
4
4
  Summary: An event-sourced reactive graph runtime for long-running, auditable, agentic systems.
5
5
  Author: Active Graph contributors
6
6
  License: MIT
@@ -55,7 +55,7 @@ from activegraph.runtime.exec_errors import (
55
55
  RuntimeContextRequiredError,
56
56
  )
57
57
  from activegraph.runtime.patterns import UnsupportedPatternError
58
- from activegraph.runtime.runtime import Runtime
58
+ from activegraph.runtime.runtime import BehaviorFailure, Runtime
59
59
  from activegraph.store import (
60
60
  CorruptedEventPayloadError,
61
61
  DuplicateEventError,
@@ -125,6 +125,7 @@ __all__ = [
125
125
  "AmbiguousToolError",
126
126
  "ApprovalNotFoundError",
127
127
  "Behavior",
128
+ "BehaviorFailure",
128
129
  "BehaviorNotFoundError",
129
130
  "Budget",
130
131
  "Clock",
@@ -221,4 +222,4 @@ __all__ = [
221
222
  "tool",
222
223
  ]
223
224
 
224
- __version__ = "1.0.2.post1"
225
+ __version__ = "1.0.3"
@@ -27,6 +27,54 @@ from activegraph.behaviors.base import (
27
27
  _REGISTRY: list[Union[Behavior, RelationBehavior]] = []
28
28
 
29
29
 
30
+ def _validate_output_schema(output_schema: Any) -> None:
31
+ """Strict-validate ``output_schema=`` at @llm_behavior time.
32
+
33
+ CONTRACT v1.0.3 #2. Accepts ``None`` (output_schema is optional)
34
+ or a Pydantic ``BaseModel`` subclass. Anything else — a dict, a
35
+ string, an instance instead of a class — raises ``TypeError``
36
+ with a structured message naming what was passed and showing the
37
+ correct form as a code example.
38
+
39
+ Dict-form output_schema support (JSON-schema dict) is a v1.1
40
+ candidate per the CONTRACT amendment; the rationale and the
41
+ fix the user wants live there.
42
+ """
43
+ if output_schema is None:
44
+ return
45
+ # Import lazily so the framework can still be imported in
46
+ # environments without Pydantic (e.g., pack metadata tooling).
47
+ # All real @llm_behavior callsites already require Pydantic
48
+ # transitively via LLMBehavior.output_schema.
49
+ try:
50
+ from pydantic import BaseModel
51
+ except ImportError: # pragma: no cover — Pydantic is a hard dep at runtime
52
+ return
53
+ if isinstance(output_schema, type) and issubclass(output_schema, BaseModel):
54
+ return
55
+ passed = (
56
+ type(output_schema).__name__
57
+ if not isinstance(output_schema, type)
58
+ else f"{output_schema.__name__} (a class, but not a BaseModel subclass)"
59
+ )
60
+ raise TypeError(
61
+ f"output_schema must be a Pydantic BaseModel subclass, not "
62
+ f"{passed}.\n"
63
+ f"\n"
64
+ f"Example:\n"
65
+ f" from pydantic import BaseModel\n"
66
+ f"\n"
67
+ f" class MyOutput(BaseModel):\n"
68
+ f" result: str\n"
69
+ f"\n"
70
+ f" @llm_behavior(output_schema=MyOutput)\n"
71
+ f" def my_behavior(event, graph, ctx, out): ...\n"
72
+ f"\n"
73
+ f"Dict-form output_schema (e.g., JSON Schema as a dict) is\n"
74
+ f"filed as a v1.1 candidate. See CONTRACT v1.0.3 #2."
75
+ )
76
+
77
+
30
78
  def clear_registry() -> list[Union[Behavior, RelationBehavior]]:
31
79
  """Empty the global behavior registry and return what was cleared.
32
80
 
@@ -227,6 +275,12 @@ def llm_behavior(
227
275
  if activate_after is not None:
228
276
  delay_n = _parse_aa(activate_after)
229
277
 
278
+ # v1.0.3 #2: strict-validate output_schema= at decoration time.
279
+ # Users who passed a JSON-schema dict previously hit a silent
280
+ # behavior.failed with reason=llm.schema_violation at first LLM
281
+ # call; failing here names the cause at the @llm_behavior line.
282
+ _validate_output_schema(output_schema)
283
+
230
284
  def wrap(fn: Callable) -> LLMBehavior:
231
285
  b = LLMBehavior(
232
286
  name=name or fn.__name__,
@@ -205,20 +205,40 @@ class Graph:
205
205
  [self._relations[i] for i in seen_rels if i in self._relations],
206
206
  )
207
207
 
208
- def query(
208
+ def objects(
209
209
  self,
210
- object_type: Optional[str] = None,
210
+ type: Optional[str] = None,
211
211
  where: Optional[dict[str, Any]] = None,
212
212
  ) -> list[Object]:
213
+ """Return objects matching `type` and/or `where`.
214
+
215
+ v1.0.3 #1: the canonical query API on `Graph`, mirroring
216
+ `View.objects(type=...)` so call sites read the same inside
217
+ and outside behaviors. `Graph.query(object_type=...)` is kept
218
+ as a backward-compatible alias.
219
+ """
213
220
  out: list[Object] = []
214
221
  for o in self._objects.values():
215
- if object_type is not None and o.type != object_type:
222
+ if type is not None and o.type != type:
216
223
  continue
217
224
  if where and not _eval_where_on_object(where, o):
218
225
  continue
219
226
  out.append(o)
220
227
  return out
221
228
 
229
+ def query(
230
+ self,
231
+ object_type: Optional[str] = None,
232
+ where: Optional[dict[str, Any]] = None,
233
+ ) -> list[Object]:
234
+ """Backward-compatible alias for :meth:`objects`. v1.0.3 #1.
235
+
236
+ New code should use ``graph.objects(type=...)`` — the kwarg
237
+ ``type`` matches :meth:`View.objects` so the call reads the
238
+ same in and out of behaviors.
239
+ """
240
+ return self.objects(type=object_type, where=where)
241
+
222
242
  def has_object_of_type(self, type_: str) -> bool:
223
243
  return any(o.type == type_ for o in self._objects.values())
224
244
 
@@ -257,7 +257,13 @@ def _message_to_anthropic(m: LLMMessage) -> dict[str, Any]:
257
257
 
258
258
  For role="tool", Anthropic wants a "user" message with a
259
259
  `tool_result` content block. For role in {"user","assistant"} the
260
- standard {role, content: str} shape works.
260
+ standard {role, content: str} shape works — except for the
261
+ multi-turn tool-use case (v1.0.3 #4), where an assistant message
262
+ that triggered tool_use must echo back its full content blocks
263
+ (text + tool_use) so the subsequent user tool_result blocks
264
+ reference matching tool_use_ids in the preceding assistant turn.
265
+ Direct Anthropic API access tolerated raw_text-only echo; the
266
+ Vertex AI proxy enforces the spec strictly and 400s without it.
261
267
  """
262
268
  if m.role == "tool":
263
269
  return {
@@ -270,6 +276,20 @@ def _message_to_anthropic(m: LLMMessage) -> dict[str, Any]:
270
276
  }
271
277
  ],
272
278
  }
279
+ if m.role == "assistant" and m.tool_calls:
280
+ blocks: list[dict[str, Any]] = []
281
+ if m.content:
282
+ blocks.append({"type": "text", "text": m.content})
283
+ for tc in m.tool_calls:
284
+ blocks.append(
285
+ {
286
+ "type": "tool_use",
287
+ "id": tc.id,
288
+ "name": tc.name,
289
+ "input": dict(tc.args),
290
+ }
291
+ )
292
+ return {"role": "assistant", "content": blocks}
273
293
  return {"role": m.role, "content": m.content}
274
294
 
275
295
 
@@ -51,7 +51,7 @@ from typing import Any, Optional
51
51
 
52
52
  from activegraph.llm.errors import LLMBehaviorError
53
53
  from activegraph.llm.provider import LLMProvider
54
- from activegraph.llm.types import LLMMessage, LLMResponse
54
+ from activegraph.llm.types import LLMMessage, LLMResponse, ToolCall
55
55
 
56
56
 
57
57
  def _now_iso() -> str:
@@ -188,6 +188,21 @@ def _response_from_fixture(
188
188
  cost = rdata.get("cost_usd", "0")
189
189
  if not isinstance(cost, Decimal):
190
190
  cost = Decimal(str(cost))
191
+ # v1.0.3 #4: reconstruct tool_calls from the fixture so multi-turn
192
+ # tool-use exchanges replay correctly. Pre-v1.0.3 fixtures and
193
+ # non-tool-using fixtures have no tool_calls key (or null) and
194
+ # round-trip to None — backward compatible.
195
+ tool_calls_raw = rdata.get("tool_calls")
196
+ tool_calls: Optional[list[ToolCall]] = None
197
+ if tool_calls_raw:
198
+ tool_calls = [
199
+ ToolCall(
200
+ id=tc.get("id", ""),
201
+ name=tc.get("name", ""),
202
+ args=dict(tc.get("args") or {}),
203
+ )
204
+ for tc in tool_calls_raw
205
+ ]
191
206
  return LLMResponse(
192
207
  raw_text=rdata.get("raw_text", ""),
193
208
  parsed=parsed,
@@ -200,6 +215,7 @@ def _response_from_fixture(
200
215
  seed=rdata.get("seed"),
201
216
  cache_hit=False,
202
217
  provider_meta=dict(rdata.get("provider_meta", {}) or {}),
218
+ tool_calls=tool_calls,
203
219
  )
204
220
 
205
221
 
@@ -43,12 +43,20 @@ class LLMMessage:
43
43
  CONTRACT v0.7: a `role="tool"` message echoes a tool result back to
44
44
  the model. `tool_use_id` ties it to the originating tool_use block
45
45
  from the previous assistant turn.
46
+
47
+ CONTRACT v1.0.3 #4: when an assistant message echoes a turn that
48
+ triggered tool_use, the originating ToolCall objects travel
49
+ alongside the raw text via `tool_calls`. The provider adapter
50
+ reconstructs the wire-format content blocks on the way out. None
51
+ on every other message shape — single-turn fixtures and zero-tool
52
+ assistant messages keep their byte-identical serialization.
46
53
  """
47
54
 
48
55
  role: Role
49
56
  content: str
50
57
  tool_use_id: Optional[str] = None
51
58
  tool_name: Optional[str] = None
59
+ tool_calls: Optional[tuple["ToolCall", ...]] = None
52
60
 
53
61
  def to_dict(self) -> dict[str, Any]:
54
62
  out: dict[str, Any] = {"role": self.role, "content": self.content}
@@ -56,6 +64,10 @@ class LLMMessage:
56
64
  out["tool_use_id"] = self.tool_use_id
57
65
  if self.tool_name is not None:
58
66
  out["tool_name"] = self.tool_name
67
+ # v1.0.3 #4: only emit `tool_calls` when present so recorded-
68
+ # fixture hashes for single-turn flows stay byte-identical.
69
+ if self.tool_calls is not None:
70
+ out["tool_calls"] = [tc.to_dict() for tc in self.tool_calls]
59
71
  return out
60
72
 
61
73
 
@@ -41,6 +41,10 @@ LOG_FIELDS: tuple[str, ...] = (
41
41
  "reason",
42
42
  "error_type",
43
43
  "error_message",
44
+ # v1.0.3 #3: behavior.failed WARNING log carries the More: URL
45
+ # for the failure reason's documentation page. Operators tail
46
+ # logs and click through to the reason's doc-page from the URL.
47
+ "doc_url",
44
48
  )
45
49
 
46
50
  # Reserved attributes on every LogRecord (stdlib internals). Any
@@ -65,7 +65,7 @@ def _monotonic() -> float:
65
65
  from dataclasses import dataclass, field
66
66
  from datetime import datetime, timezone
67
67
  from decimal import Decimal
68
- from typing import Any, Callable, Iterable, Optional, Union
68
+ from typing import Any, Callable, Iterable, NamedTuple, Optional, Union
69
69
 
70
70
  from activegraph.behaviors.base import Behavior, LLMBehavior, RelationBehavior
71
71
  from activegraph.behaviors.decorators import get_registry
@@ -170,6 +170,58 @@ class Context:
170
170
  )
171
171
 
172
172
 
173
+ class BehaviorFailure(NamedTuple):
174
+ """Structured view of a ``behavior.failed`` event for
175
+ :attr:`Runtime.errors`. CONTRACT v1.0.3 #3.
176
+
177
+ Five fields capture the operationally important parts of a
178
+ failure: which behavior, which event triggered it, the v0.6 #11
179
+ reason code (when present), the Python exception class name, and
180
+ the exception message. ``failed_event_id`` ties the structured
181
+ view back to the underlying ``behavior.failed`` event so callers
182
+ that want the full payload (traceback, payload extras) can
183
+ re-read it from ``runtime.graph._events``.
184
+
185
+ Not exposed as a class users construct — it's a projection of
186
+ the events the runtime emits, and the runtime is the only writer.
187
+ Distinct from Python's builtin ``RuntimeError``; named
188
+ ``BehaviorFailure`` to avoid the shadow.
189
+ """
190
+
191
+ behavior: str
192
+ event_id: str
193
+ reason: Optional[str]
194
+ exception_type: str
195
+ message: str
196
+ failed_event_id: str
197
+
198
+
199
+ # Map v0.6 #11 reason-code prefixes to the framework error class's
200
+ # doc-page slug. Used by the WARNING log emitted from
201
+ # `_emit_behavior_failed` so the log line carries a More: URL the
202
+ # operator can open. The slugs are class-level (the v1.0 #4 More:
203
+ # URL convention) — a per-reason slug doesn't exist today.
204
+ _REASON_PREFIX_TO_DOC_SLUG: tuple[tuple[str, str], ...] = (
205
+ ("llm.", "llm-behavior-error"),
206
+ ("tool.", "tool-error"),
207
+ ("budget.", "budget-exhausted"),
208
+ )
209
+
210
+
211
+ def _doc_url_for_reason(reason: str) -> str:
212
+ """Return the More: doc-page URL for a v0.6 #11 reason code.
213
+
214
+ Defaults to the generic execution-error page when no prefix
215
+ matches (covers ``exception.*`` reasons from generic catches).
216
+ """
217
+ from activegraph.errors import DOCS_BASE_URL
218
+
219
+ for prefix, slug in _REASON_PREFIX_TO_DOC_SLUG:
220
+ if reason.startswith(prefix):
221
+ return f"{DOCS_BASE_URL}/errors/{slug}"
222
+ return f"{DOCS_BASE_URL}/errors/execution-error"
223
+
224
+
173
225
  class Runtime:
174
226
  def __init__(
175
227
  self,
@@ -326,6 +378,38 @@ class Runtime:
326
378
  def run_id(self) -> str:
327
379
  return self.graph.run_id
328
380
 
381
+ @property
382
+ def errors(self) -> list[BehaviorFailure]:
383
+ """Accumulated ``behavior.failed`` events as structured tuples.
384
+
385
+ v1.0.3 #3. Reads from ``self.graph._events`` on each access —
386
+ the events are the source of truth and this property is a
387
+ projection. No caching, no listener registration, no new
388
+ state. Callers can inspect failures programmatically without
389
+ reaching into ``graph._events`` or parsing payload dicts.
390
+
391
+ Each :class:`BehaviorFailure` carries five operationally
392
+ useful fields plus the underlying ``behavior.failed`` event
393
+ id for callers that want to re-read the full payload (e.g.,
394
+ traceback, LLM payload extras).
395
+ """
396
+ out: list[BehaviorFailure] = []
397
+ for e in self.graph._events:
398
+ if e.type != "behavior.failed":
399
+ continue
400
+ p = e.payload
401
+ out.append(
402
+ BehaviorFailure(
403
+ behavior=p.get("behavior", ""),
404
+ event_id=p.get("event_id", ""),
405
+ reason=p.get("reason"),
406
+ exception_type=p.get("exception_type", ""),
407
+ message=p.get("message", ""),
408
+ failed_event_id=e.id,
409
+ )
410
+ )
411
+ return out
412
+
329
413
  # ---------- listener ----------
330
414
 
331
415
  def _on_event(self, event: Event) -> None:
@@ -661,27 +745,12 @@ class Runtime:
661
745
  "activegraph_behaviors_failed_total",
662
746
  {"behavior": b.name, "reason": f"exception.{type(e).__name__}"},
663
747
  )
664
- self._log.error(
665
- "behavior failed",
666
- extra=runtime_log_extra(
667
- run_id=self.graph.run_id,
668
- event_id=event.id,
669
- behavior=b.name,
670
- reason=f"exception.{type(e).__name__}",
671
- error_type=type(e).__name__,
672
- error_message=str(e),
673
- ),
674
- )
675
- self._emit_lifecycle(
676
- "behavior.failed",
677
- {
678
- "behavior": b.name,
679
- "event_id": event.id,
680
- "exception_type": type(e).__name__,
681
- "message": str(e),
682
- "traceback": traceback.format_exc(),
683
- },
684
- )
748
+ # v1.0.3 #3: route through _emit_behavior_failed so the
749
+ # WARNING log line and the event emission stay in one
750
+ # place. The previous ERROR log is removed — the
751
+ # centralized emitter handles logging at WARNING for
752
+ # every behavior.failed.
753
+ self._emit_behavior_failed(b.name, event.id, e)
685
754
  return
686
755
 
687
756
  self.metrics.histogram(
@@ -974,14 +1043,22 @@ class Runtime:
974
1043
  break
975
1044
 
976
1045
  # Tool calls. Append the assistant turn to messages, then
977
- # dispatch each tool. Anthropic's assistant turn that
978
- # triggers tool_use carries the model's reasoning text plus
979
- # the tool_use blocks; we approximate by including raw_text
980
- # (may be empty) the providers' adapters handle the rest.
981
- if turn_response.raw_text:
982
- running_messages.append(
983
- LLMMessage(role="assistant", content=turn_response.raw_text)
1046
+ # dispatch each tool. v1.0.3 #4: the assistant turn must
1047
+ # carry both the text and the tool_use blocks. Anthropic
1048
+ # requires every following tool_result block to reference
1049
+ # a tool_use_id from the preceding assistant message; the
1050
+ # Vertex AI proxy enforces this strictly (HTTP 400 without
1051
+ # matching blocks), and the direct API tolerates the raw-
1052
+ # text-only form for now but is expected to tighten. The
1053
+ # provider adapter reconstructs the wire-format content
1054
+ # blocks from `tool_calls`.
1055
+ running_messages.append(
1056
+ LLMMessage(
1057
+ role="assistant",
1058
+ content=turn_response.raw_text or "",
1059
+ tool_calls=tuple(response_tool_calls),
984
1060
  )
1061
+ )
985
1062
  for call in response_tool_calls:
986
1063
  # CONTRACT v0.7 #6: budget enforcement BEFORE invocation
987
1064
  # so an exhausted budget fails the behavior, doesn't
@@ -1411,6 +1488,14 @@ class Runtime:
1411
1488
  ) -> None:
1412
1489
  """Centralized behavior.failed emission. CONTRACT #13 plus
1413
1490
  v0.6 #11 (optional `reason` + LLM-specific extras).
1491
+
1492
+ v1.0.3 #3: emits a WARNING log line so callers running
1493
+ ``runtime.run_goal()`` see failures without subscribing to
1494
+ ``behavior.failed`` or inspecting ``graph._events``. Users
1495
+ opt out via standard ``logging.getLogger('activegraph.runtime')``
1496
+ configuration. Every ``behavior.failed`` emission routes
1497
+ through this method so exactly one log line is produced per
1498
+ failure at one consistent level.
1414
1499
  """
1415
1500
 
1416
1501
  tb_str = "".join(
@@ -1429,6 +1514,24 @@ class Runtime:
1429
1514
  for k, v in extras.items():
1430
1515
  if k not in payload:
1431
1516
  payload[k] = v
1517
+ # v1.0.3 #3: WARNING log with doc_url pointing at the reason's
1518
+ # documentation page. Slug is class-level (the v1.0 #4 More:
1519
+ # URL convention) — LLMBehaviorError for llm.*, ToolError for
1520
+ # tool.*, ConfigurationError for budget.*, else the generic
1521
+ # execution-error page.
1522
+ log_reason = reason or f"exception.{type(exc).__name__}"
1523
+ self._log.warning(
1524
+ f"behavior failed: {behavior_name} (reason={log_reason})",
1525
+ extra=runtime_log_extra(
1526
+ run_id=self.graph.run_id,
1527
+ event_id=event_id,
1528
+ behavior=behavior_name,
1529
+ reason=log_reason,
1530
+ error_type=type(exc).__name__,
1531
+ error_message=str(exc),
1532
+ doc_url=_doc_url_for_reason(log_reason),
1533
+ ),
1534
+ )
1432
1535
  self._emit_lifecycle("behavior.failed", payload)
1433
1536
 
1434
1537
  def _invoke_relation(
@@ -1470,16 +1573,10 @@ class Runtime:
1470
1573
  try:
1471
1574
  b.run(relation, event, bgraph, ctx)
1472
1575
  except Exception as e:
1473
- self._emit_lifecycle(
1474
- "behavior.failed",
1475
- {
1476
- "behavior": b.name,
1477
- "event_id": event.id,
1478
- "exception_type": type(e).__name__,
1479
- "message": str(e),
1480
- "traceback": traceback.format_exc(),
1481
- },
1482
- )
1576
+ # v1.0.3 #3: route through _emit_behavior_failed so
1577
+ # relation-behavior failures go through the same WARNING
1578
+ # log + event emission path as function and LLM behaviors.
1579
+ self._emit_behavior_failed(b.name, event.id, e)
1483
1580
  return
1484
1581
 
1485
1582
  self._emit_lifecycle(
@@ -68,7 +68,7 @@ def make_graph_query_tool(graph) -> Tool:
68
68
  """
69
69
 
70
70
  def fn(args: GraphQueryInput, ctx: ToolContext) -> GraphQueryOutput:
71
- results = graph.query(object_type=args.object_type, where=args.where)
71
+ results = graph.objects(type=args.object_type, where=args.where)
72
72
  truncated = len(results) > args.limit
73
73
  results = results[: args.limit]
74
74
  return GraphQueryOutput(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activegraph
3
- Version: 1.0.2.post1
3
+ Version: 1.0.3
4
4
  Summary: An event-sourced reactive graph runtime for long-running, auditable, agentic systems.
5
5
  Author: Active Graph contributors
6
6
  License: MIT
@@ -149,6 +149,8 @@ tests/test_tools.py
149
149
  tests/test_trace.py
150
150
  tests/test_tutorial_snippets.py
151
151
  tests/test_v1_0_1_patches.py
152
+ tests/test_v1_0_3_behavior_failed_ux.py
153
+ tests/test_v1_0_3_tool_multiturn.py
152
154
  tests/test_version_sync.py
153
155
  tests/test_view.py
154
156
  tests/test_wheel_completeness.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "activegraph"
7
- version = "1.0.2.post1"
7
+ version = "1.0.3"
8
8
  description = "An event-sourced reactive graph runtime for long-running, auditable, agentic systems."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -68,6 +68,67 @@ def test_query_filters_by_type_and_where():
68
68
  assert high[0].data["text"] == "x"
69
69
 
70
70
 
71
+ # v1.0.3 #1: graph.objects(type=...) as the canonical query API,
72
+ # matching View.objects(type=...) so call sites read the same inside
73
+ # and outside behaviors. graph.query(object_type=...) stays as a
74
+ # backward-compatible alias.
75
+
76
+
77
+ def test_objects_filters_by_type():
78
+ g = _g()
79
+ g.add_object("claim", {"text": "x"})
80
+ g.add_object("claim", {"text": "y"})
81
+ g.add_object("task", {"title": "t"})
82
+
83
+ claims = g.objects(type="claim")
84
+ assert {o.data["text"] for o in claims} == {"x", "y"}
85
+ assert all(o.type == "claim" for o in claims)
86
+
87
+
88
+ def test_objects_filters_by_where():
89
+ g = _g()
90
+ g.add_object("claim", {"text": "x", "confidence": 0.9})
91
+ g.add_object("claim", {"text": "y", "confidence": 0.4})
92
+
93
+ high = g.objects(type="claim", where={"confidence": {">": 0.5}})
94
+ assert len(high) == 1
95
+ assert high[0].data["text"] == "x"
96
+
97
+
98
+ def test_objects_with_no_kwargs_returns_every_object():
99
+ # Same semantics as View.objects() with no args: full slice.
100
+ # graph.objects() is a strict superset of graph.all_objects()
101
+ # (which is kept for callers that prefer the explicit-no-filter
102
+ # name and for backward compat).
103
+ g = _g()
104
+ g.add_object("claim", {"text": "x"})
105
+ g.add_object("task", {"title": "t"})
106
+
107
+ assert {o.id for o in g.objects()} == {o.id for o in g.all_objects()}
108
+
109
+
110
+ def test_objects_and_query_return_the_same_results():
111
+ g = _g()
112
+ g.add_object("claim", {"text": "x", "confidence": 0.9})
113
+ g.add_object("claim", {"text": "y", "confidence": 0.4})
114
+ g.add_object("task", {"title": "t"})
115
+
116
+ new = g.objects(type="claim", where={"confidence": {">": 0.5}})
117
+ old = g.query(object_type="claim", where={"confidence": {">": 0.5}})
118
+ assert [o.id for o in new] == [o.id for o in old]
119
+
120
+
121
+ def test_query_alias_still_works_with_positional_arg():
122
+ # External code that passed the type positionally to query()
123
+ # continues to work — the alias keeps the (object_type=, where=)
124
+ # kwarg shape and the parameter order from v1.0.2.
125
+ g = _g()
126
+ g.add_object("claim", {"text": "x"})
127
+ g.add_object("task", {"title": "t"})
128
+
129
+ assert {o.type for o in g.query("claim")} == {"claim"}
130
+
131
+
71
132
  def test_neighborhood_walks_to_depth():
72
133
  g = _g()
73
134
  a = g.add_object("task", {})