harborrag-engine 2.0.0a1__py3-none-any.whl

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 (102) hide show
  1. harborrag_engine/__init__.py +3 -0
  2. harborrag_engine/agent/__init__.py +37 -0
  3. harborrag_engine/agent/events.py +38 -0
  4. harborrag_engine/agent/execution.py +188 -0
  5. harborrag_engine/agent/guard.py +91 -0
  6. harborrag_engine/agent/helpers.py +68 -0
  7. harborrag_engine/agent/loop.py +343 -0
  8. harborrag_engine/agent/loop_state.py +54 -0
  9. harborrag_engine/agent/protocols.py +39 -0
  10. harborrag_engine/agent/run_lifecycle.py +126 -0
  11. harborrag_engine/agent/schemas.py +36 -0
  12. harborrag_engine/agent/service.py +186 -0
  13. harborrag_engine/agent/token_budget.py +55 -0
  14. harborrag_engine/agent/tool_execution.py +199 -0
  15. harborrag_engine/config.py +7 -0
  16. harborrag_engine/conversation/__init__.py +5 -0
  17. harborrag_engine/conversation/memory.py +9 -0
  18. harborrag_engine/ingestion/__init__.py +125 -0
  19. harborrag_engine/ingestion/admission.py +265 -0
  20. harborrag_engine/ingestion/base.py +35 -0
  21. harborrag_engine/ingestion/chunking/README.md +68 -0
  22. harborrag_engine/ingestion/chunking/__init__.py +90 -0
  23. harborrag_engine/ingestion/chunking/config.py +211 -0
  24. harborrag_engine/ingestion/chunking/errors.py +26 -0
  25. harborrag_engine/ingestion/chunking/identity/__init__.py +19 -0
  26. harborrag_engine/ingestion/chunking/identity/assignment.py +82 -0
  27. harborrag_engine/ingestion/chunking/identity/fingerprint.py +7 -0
  28. harborrag_engine/ingestion/chunking/pipeline/__init__.py +6 -0
  29. harborrag_engine/ingestion/chunking/pipeline/candidates.py +150 -0
  30. harborrag_engine/ingestion/chunking/pipeline/composition.py +64 -0
  31. harborrag_engine/ingestion/chunking/pipeline/result.py +149 -0
  32. harborrag_engine/ingestion/chunking/pipeline/service.py +31 -0
  33. harborrag_engine/ingestion/chunking/records/__init__.py +16 -0
  34. harborrag_engine/ingestion/chunking/records/context.py +131 -0
  35. harborrag_engine/ingestion/chunking/records/factory.py +261 -0
  36. harborrag_engine/ingestion/chunking/records/hierarchy.py +61 -0
  37. harborrag_engine/ingestion/chunking/records/rebind.py +41 -0
  38. harborrag_engine/ingestion/chunking/records/validation.py +217 -0
  39. harborrag_engine/ingestion/chunking/schemas.py +301 -0
  40. harborrag_engine/ingestion/chunking/sources/__init__.py +13 -0
  41. harborrag_engine/ingestion/chunking/sources/base.py +25 -0
  42. harborrag_engine/ingestion/chunking/sources/canonical.py +31 -0
  43. harborrag_engine/ingestion/chunking/sources/confluence.py +70 -0
  44. harborrag_engine/ingestion/chunking/sources/jira.py +338 -0
  45. harborrag_engine/ingestion/chunking/sources/registry.py +37 -0
  46. harborrag_engine/ingestion/chunking/sources/validation.py +29 -0
  47. harborrag_engine/ingestion/chunking/table/README.md +16 -0
  48. harborrag_engine/ingestion/chunking/table/__init__.py +1 -0
  49. harborrag_engine/ingestion/chunking/table/classifier.py +203 -0
  50. harborrag_engine/ingestion/chunking/table/errors.py +13 -0
  51. harborrag_engine/ingestion/chunking/table/factory.py +299 -0
  52. harborrag_engine/ingestion/chunking/table/fragmentation.py +68 -0
  53. harborrag_engine/ingestion/chunking/table/grouping.py +93 -0
  54. harborrag_engine/ingestion/chunking/table/models.py +132 -0
  55. harborrag_engine/ingestion/chunking/table/planner.py +277 -0
  56. harborrag_engine/ingestion/chunking/table/policy.py +96 -0
  57. harborrag_engine/ingestion/chunking/table/rendering.py +172 -0
  58. harborrag_engine/ingestion/chunking/table/service.py +141 -0
  59. harborrag_engine/ingestion/chunking/table/validator.py +174 -0
  60. harborrag_engine/ingestion/chunking/transforms/__init__.py +15 -0
  61. harborrag_engine/ingestion/chunking/transforms/packing.py +223 -0
  62. harborrag_engine/ingestion/chunking/transforms/refinement.py +253 -0
  63. harborrag_engine/ingestion/chunking/transforms/routing.py +195 -0
  64. harborrag_engine/ingestion/chunking/transforms/segmentation.py +274 -0
  65. harborrag_engine/ingestion/normalizer.py +217 -0
  66. harborrag_engine/ingestion/policies.py +210 -0
  67. harborrag_engine/ingestion/projections/__init__.py +43 -0
  68. harborrag_engine/ingestion/projections/graph/__init__.py +37 -0
  69. harborrag_engine/ingestion/projections/graph/collaboration_source_projectors.py +284 -0
  70. harborrag_engine/ingestion/projections/graph/file_source_projectors.py +197 -0
  71. harborrag_engine/ingestion/projections/graph/graph.py +209 -0
  72. harborrag_engine/ingestion/projections/graph/graph_models.py +179 -0
  73. harborrag_engine/ingestion/projections/graph/graph_state.py +292 -0
  74. harborrag_engine/ingestion/projections/graph/graph_structure.py +248 -0
  75. harborrag_engine/ingestion/projections/graph/source_projector_support.py +217 -0
  76. harborrag_engine/ingestion/projections/graph/source_projectors.py +96 -0
  77. harborrag_engine/ingestion/projections/vector/__init__.py +18 -0
  78. harborrag_engine/ingestion/projections/vector/vector.py +97 -0
  79. harborrag_engine/ingestion/projections/vector/vector_store.py +260 -0
  80. harborrag_engine/ingestion/projections/verification.py +222 -0
  81. harborrag_engine/ingestion/representations/__init__.py +10 -0
  82. harborrag_engine/ingestion/representations/encoding.py +142 -0
  83. harborrag_engine/ingestion/representations/reuse.py +160 -0
  84. harborrag_engine/ingestion/representations/sparse.py +80 -0
  85. harborrag_engine/ingestion/tables.py +222 -0
  86. harborrag_engine/ingestion/title_content.py +64 -0
  87. harborrag_engine/policy.py +11 -0
  88. harborrag_engine/retrieval/__init__.py +42 -0
  89. harborrag_engine/retrieval/active_versions.py +76 -0
  90. harborrag_engine/retrieval/authoritative.py +212 -0
  91. harborrag_engine/retrieval/evidence.py +53 -0
  92. harborrag_engine/retrieval/fusion.py +29 -0
  93. harborrag_engine/retrieval/graph.py +275 -0
  94. harborrag_engine/retrieval/pipeline.py +151 -0
  95. harborrag_engine/retrieval/ports.py +52 -0
  96. harborrag_engine/retrieval/reranking.py +5 -0
  97. harborrag_engine/retrieval/rewriting.py +2 -0
  98. harborrag_engine/testing/edge_signatures.py +33 -0
  99. harborrag_engine-2.0.0a1.dist-info/METADATA +121 -0
  100. harborrag_engine-2.0.0a1.dist-info/RECORD +102 -0
  101. harborrag_engine-2.0.0a1.dist-info/WHEEL +4 -0
  102. harborrag_engine-2.0.0a1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,3 @@
1
+ """HarborRAG business workflows for ingestion and retrieval."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,37 @@
1
+ """Provider-neutral agent orchestration."""
2
+
3
+ from harborrag_core.ports.agent_runs import (
4
+ AgentCheckpoint,
5
+ AgentRunIdentity,
6
+ AgentRunRepository,
7
+ AgentRunStatus,
8
+ AgentStopReason,
9
+ AgentToolExecution,
10
+ )
11
+
12
+ from .events import AgentEvent, AgentEventSink
13
+ from .guard import ExecutionGuard
14
+ from .service import (
15
+ AgentRunOptions,
16
+ AgentRunResult,
17
+ AgentService,
18
+ AgentToolProvider,
19
+ AgentToolSpec,
20
+ )
21
+
22
+ __all__ = [
23
+ "AgentCheckpoint",
24
+ "AgentEvent",
25
+ "AgentEventSink",
26
+ "AgentRunIdentity",
27
+ "AgentRunOptions",
28
+ "AgentRunRepository",
29
+ "AgentRunResult",
30
+ "AgentRunStatus",
31
+ "AgentService",
32
+ "AgentStopReason",
33
+ "AgentToolExecution",
34
+ "AgentToolProvider",
35
+ "AgentToolSpec",
36
+ "ExecutionGuard",
37
+ ]
@@ -0,0 +1,38 @@
1
+ """Transport-neutral progress events emitted by the agent loop.
2
+
3
+ The agent loop knows nothing about SSE, WebSockets, or logging -- it emits
4
+ ``AgentEvent`` to an injected sink, and a transport layer decides how to
5
+ render each one. Passing no sink (the default) costs nothing: ``run()``
6
+ and ``resume()`` skip emission entirely when ``events`` is ``None``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Awaitable, Callable, Mapping
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class AgentEvent:
17
+ """One point-in-time occurrence during an agent run.
18
+
19
+ ``kind`` is a stable dotted name (``"run.started"``, ``"tool.completed"``,
20
+ ...) a transport can use as a discriminator; ``data`` carries only
21
+ JSON-serializable, already-public fields -- the same values that appear
22
+ in ``AgentRunResult``/``AgentToolExecution``.
23
+ """
24
+
25
+ kind: str
26
+ run_id: str
27
+ data: Mapping[str, object]
28
+
29
+
30
+ AgentEventSink = Callable[[AgentEvent], Awaitable[None]]
31
+
32
+
33
+ async def emit(sink: AgentEventSink | None, event: AgentEvent) -> None:
34
+ if sink is not None:
35
+ await sink(event)
36
+
37
+
38
+ __all__ = ["AgentEvent", "AgentEventSink", "emit"]
@@ -0,0 +1,188 @@
1
+ """Model-turn and tool-call execution primitives used by the agent loop.
2
+
3
+ Split out of ``loop.py`` so that module stays focused on step orchestration
4
+ (guard checks, checkpointing, stop-reason bookkeeping) while this one owns
5
+ the actual calls out to the chat model and the tool provider.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from collections.abc import Sequence
12
+
13
+ from harborrag_core.models.chat import (
14
+ HarborChatMessage,
15
+ HarborChatMetadata,
16
+ HarborChatRequest,
17
+ HarborChatResponse,
18
+ HarborChatTool,
19
+ HarborToolCall,
20
+ )
21
+ from harborrag_core.ports.agent_runs import AgentToolExecution
22
+ from harborrag_engine.conversation import ConversationIdentity, ConversationMemory, ConversationTurn
23
+
24
+ from .guard import ExecutionGuard, digest_arguments
25
+ from .protocols import AgentChatModel, AgentToolProvider, AgentToolSpec
26
+ from .schemas import AgentRunOptions
27
+ from .tool_execution import VECTOR_SEARCH_TOOL, bounded_tool_result_content
28
+
29
+ _BLOCKED_TOOL_NAMES = frozenset({"agent", "chat"})
30
+ _GRAPH_TOOL_PREFIX = "graph_"
31
+
32
+
33
+ class ChatAndToolExecutor:
34
+ """Bind one model turn or one batch of tool calls to their transports."""
35
+
36
+ def __init__(
37
+ self,
38
+ chat: AgentChatModel,
39
+ tools: AgentToolProvider,
40
+ *,
41
+ memory: ConversationMemory | None,
42
+ ) -> None:
43
+ self._chat = chat
44
+ self._tools = tools
45
+ self._memory = memory
46
+
47
+ def available_specs(
48
+ self,
49
+ tenant_id: str,
50
+ *,
51
+ graph_search: bool,
52
+ ) -> list[AgentToolSpec]:
53
+ return [
54
+ spec
55
+ for spec in self._tools.list_tools(tenant_id)
56
+ if spec.capability == "read"
57
+ and spec.name not in _BLOCKED_TOOL_NAMES
58
+ and (graph_search or not spec.name.startswith(_GRAPH_TOOL_PREFIX))
59
+ ]
60
+
61
+ async def complete(
62
+ self,
63
+ messages: Sequence[HarborChatMessage],
64
+ options: AgentRunOptions,
65
+ *,
66
+ tools: tuple[HarborChatTool, ...],
67
+ guard: ExecutionGuard | None = None,
68
+ completion_token_limit: int | None = None,
69
+ ) -> HarborChatResponse:
70
+ request = HarborChatRequest(
71
+ messages=tuple(messages),
72
+ tools=tools,
73
+ parallel_tool_calls=True if tools else None,
74
+ max_completion_tokens=completion_token_limit,
75
+ metadata=HarborChatMetadata(
76
+ tenant_id=options.tenant_id,
77
+ conversation_id=options.session_id,
78
+ ),
79
+ sensitive=True,
80
+ )
81
+ coro = self._chat.complete(request)
82
+ remaining = guard.remaining_seconds() if guard is not None else None
83
+ if remaining is None:
84
+ return await coro
85
+ return await asyncio.wait_for(coro, timeout=remaining)
86
+
87
+ async def execute_tool_calls(
88
+ self,
89
+ calls: Sequence[HarborToolCall],
90
+ *,
91
+ step: int,
92
+ options: AgentRunOptions,
93
+ allowed_names: set[str],
94
+ guard: ExecutionGuard,
95
+ ) -> tuple[tuple[HarborChatMessage, AgentToolExecution], ...]:
96
+ coro = asyncio.gather(
97
+ *(
98
+ self._execute(call, step=step, options=options, allowed_names=allowed_names)
99
+ for call in calls
100
+ )
101
+ )
102
+ remaining = guard.remaining_seconds()
103
+ if remaining is None:
104
+ return tuple(await coro)
105
+ return tuple(await asyncio.wait_for(coro, timeout=remaining))
106
+
107
+ async def remember(
108
+ self,
109
+ identity: ConversationIdentity | None,
110
+ current_user_message: HarborChatMessage | None,
111
+ response_text: str,
112
+ ) -> None:
113
+ if (
114
+ self._memory is not None
115
+ and identity is not None
116
+ and current_user_message is not None
117
+ and isinstance(current_user_message.content, str)
118
+ ):
119
+ await self._memory.append(
120
+ identity,
121
+ ConversationTurn(current_user_message.content, response_text),
122
+ )
123
+
124
+ async def _execute(
125
+ self,
126
+ call: HarborToolCall,
127
+ *,
128
+ step: int,
129
+ options: AgentRunOptions,
130
+ allowed_names: set[str],
131
+ ) -> tuple[HarborChatMessage, AgentToolExecution]:
132
+ name = call.function.name
133
+ arguments = call.function.parsed_arguments
134
+ digest = digest_arguments(
135
+ arguments if isinstance(arguments, dict) else {"__unparsed__": call.function.arguments}
136
+ )
137
+ result = await self._invoke(name, arguments, options=options, allowed_names=allowed_names)
138
+ content = bounded_tool_result_content(result)
139
+ return (
140
+ HarborChatMessage.tool(content, tool_call_id=call.id, name=name),
141
+ AgentToolExecution(
142
+ step=step,
143
+ call_id=call.id,
144
+ tool=name,
145
+ ok=result.get("ok") is True,
146
+ arguments_digest=digest,
147
+ ),
148
+ )
149
+
150
+ async def _invoke(
151
+ self,
152
+ name: str,
153
+ arguments: object,
154
+ *,
155
+ options: AgentRunOptions,
156
+ allowed_names: set[str],
157
+ ) -> dict[str, object]:
158
+ """Run one tool call, or explain why it can't run, without ever raising."""
159
+
160
+ if not isinstance(arguments, dict):
161
+ return {"ok": False, "error": "invalid tool arguments"}
162
+ if name not in allowed_names:
163
+ return {"ok": False, "error": "tool is not available to this agent"}
164
+ try:
165
+ return await self._tools.call_tool(
166
+ name,
167
+ self._scoped_arguments(name, arguments, options),
168
+ principal_id=options.principal_id,
169
+ )
170
+ except Exception: # noqa: BLE001 - tool failures become model-visible data
171
+ return {"ok": False, "error": "tool call failed"}
172
+
173
+ @staticmethod
174
+ def _scoped_arguments(
175
+ name: str,
176
+ arguments: dict[str, object],
177
+ options: AgentRunOptions,
178
+ ) -> dict[str, object]:
179
+ """Bind a tool call to its caller's tenant, never trusting the model for it."""
180
+
181
+ scoped = dict(arguments)
182
+ scoped["tenant_id"] = options.tenant_id
183
+ if name == VECTOR_SEARCH_TOOL and not options.graph_search:
184
+ scoped["observe_graph"] = False
185
+ return scoped
186
+
187
+
188
+ __all__ = ["ChatAndToolExecutor"]
@@ -0,0 +1,91 @@
1
+ """Runaway-loop protection for the bounded agent loop.
2
+
3
+ ``AgentRunOptions.max_steps`` alone does not stop a pathological run: a model
4
+ can spend its entire step budget calling the same tool with the same
5
+ arguments, or one step can hang indefinitely on a slow tool. ``ExecutionGuard``
6
+ adds the two checks a step-count bound cannot express: a wall-clock deadline
7
+ and repeated-identical-tool-call detection.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import time
14
+ from collections.abc import Iterable
15
+ from dataclasses import dataclass, field
16
+ from json import dumps
17
+
18
+ from harborrag_core.ports.agent_runs import AgentToolExecution
19
+
20
+
21
+ def digest_arguments(arguments: dict[str, object]) -> str:
22
+ """Stable hash of tool-call arguments, used as a repeat-detection key.
23
+
24
+ Only the digest is ever persisted or compared -- never the raw
25
+ arguments -- so this is safe to store in a public execution trace.
26
+ """
27
+
28
+ canonical = dumps(arguments, sort_keys=True, separators=(",", ":"), default=str)
29
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class ExecutionGuard:
34
+ """Bound wall-clock time and repeated tool calls within one run attempt.
35
+
36
+ A fresh guard is armed for each call to ``run()`` or ``resume()`` --
37
+ ``timeout_seconds`` is a budget for *this* execution attempt, not the
38
+ run's total lifetime, since a resumed run may follow an arbitrarily long
39
+ gap after a crash.
40
+ """
41
+
42
+ timeout_seconds: float | None = None
43
+ max_repeated_tool_calls: int = 2
44
+ _deadline: float | None = field(default=None, init=False, repr=False)
45
+ _call_counts: dict[tuple[str, str], int] = field(
46
+ default_factory=dict,
47
+ init=False,
48
+ repr=False,
49
+ )
50
+
51
+ def __post_init__(self) -> None:
52
+ if self.timeout_seconds is not None and self.timeout_seconds <= 0:
53
+ raise ValueError("agent timeout_seconds must be positive")
54
+ if self.max_repeated_tool_calls < 1:
55
+ raise ValueError("agent max_repeated_tool_calls must be at least 1")
56
+
57
+ def start(self) -> None:
58
+ """Arm the deadline. Call exactly once at the start of an execution attempt."""
59
+
60
+ self._deadline = (
61
+ None if self.timeout_seconds is None else time.monotonic() + self.timeout_seconds
62
+ )
63
+
64
+ def remaining_seconds(self) -> float | None:
65
+ """Seconds left before the deadline, or ``None`` when unbounded."""
66
+
67
+ if self._deadline is None:
68
+ return None
69
+ return max(0.0, self._deadline - time.monotonic())
70
+
71
+ def timed_out(self) -> bool:
72
+ remaining = self.remaining_seconds()
73
+ return remaining is not None and remaining <= 0
74
+
75
+ def observe_tool_call(self, tool: str, arguments_digest: str) -> bool:
76
+ """Record one tool call; return ``True`` once its repeat limit is exceeded."""
77
+
78
+ key = (tool, arguments_digest)
79
+ count = self._call_counts.get(key, 0) + 1
80
+ self._call_counts[key] = count
81
+ return count > self.max_repeated_tool_calls
82
+
83
+ def replay(self, executions: Iterable[AgentToolExecution]) -> None:
84
+ """Rebuild repeated-call state from a checkpoint's executions on resume."""
85
+
86
+ for execution in executions:
87
+ key = (execution.tool, execution.arguments_digest)
88
+ self._call_counts[key] = self._call_counts.get(key, 0) + 1
89
+
90
+
91
+ __all__ = ["ExecutionGuard", "digest_arguments"]
@@ -0,0 +1,68 @@
1
+ """Small pure helpers shared across the agent loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from harborrag_core.models.chat import HarborChatMessage, HarborChatUsage, MessageRole
8
+ from harborrag_engine.conversation import ConversationTurn
9
+
10
+ from .schemas import AgentRunOptions
11
+
12
+
13
+ def validate_options(options: AgentRunOptions) -> None:
14
+ for field_name, value in (
15
+ ("tenant_id", options.tenant_id),
16
+ ("principal_id", options.principal_id),
17
+ ("session_id", options.session_id),
18
+ ):
19
+ if not value.strip() or len(value) > 128:
20
+ raise ValueError(f"agent {field_name} must contain 1 to 128 non-whitespace characters")
21
+ if not 1 <= options.max_steps <= 8:
22
+ raise ValueError("agent max_steps must be between 1 and 8")
23
+ if options.timeout_seconds is not None and options.timeout_seconds <= 0:
24
+ raise ValueError("agent timeout_seconds must be positive")
25
+ if options.max_repeated_tool_calls < 1:
26
+ raise ValueError("agent max_repeated_tool_calls must be at least 1")
27
+ if options.synthesis_timeout_seconds is not None and options.synthesis_timeout_seconds <= 0:
28
+ raise ValueError("agent synthesis_timeout_seconds must be positive")
29
+ if options.max_total_tokens is not None and options.max_total_tokens < 1:
30
+ raise ValueError("agent max_total_tokens must be at least 1")
31
+
32
+
33
+ def add_usage(left: HarborChatUsage, right: HarborChatUsage) -> HarborChatUsage:
34
+ def optional_sum(name: str) -> int | None:
35
+ first = getattr(left, name)
36
+ second = getattr(right, name)
37
+ return None if first is None and second is None else (first or 0) + (second or 0)
38
+
39
+ return HarborChatUsage(
40
+ prompt_tokens=left.prompt_tokens + right.prompt_tokens,
41
+ completion_tokens=left.completion_tokens + right.completion_tokens,
42
+ total_tokens=left.total_tokens + right.total_tokens,
43
+ cache_read_input_tokens=optional_sum("cache_read_input_tokens"),
44
+ cache_creation_input_tokens=optional_sum("cache_creation_input_tokens"),
45
+ reasoning_tokens=optional_sum("reasoning_tokens"),
46
+ )
47
+
48
+
49
+ def turn_messages(turns: Sequence[ConversationTurn]) -> list[HarborChatMessage]:
50
+ messages: list[HarborChatMessage] = []
51
+ for turn in turns:
52
+ messages.extend(
53
+ (
54
+ HarborChatMessage.user(turn.user_content),
55
+ HarborChatMessage.assistant(turn.assistant_content),
56
+ )
57
+ )
58
+ return messages
59
+
60
+
61
+ def last_user_message(messages: Sequence[HarborChatMessage]) -> HarborChatMessage | None:
62
+ return next(
63
+ (message for message in reversed(messages) if message.role is MessageRole.USER),
64
+ None,
65
+ )
66
+
67
+
68
+ __all__ = ["add_usage", "last_user_message", "turn_messages", "validate_options"]