ags-cli 0.1.0__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.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- harness/workers/inprocess.py +135 -0
harness/mcp/tools.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Pure helpers for turning MCP server tools into the harness's tool protocol.
|
|
2
|
+
|
|
3
|
+
No I/O and no MCP SDK import here — this is the testable core shared by both
|
|
4
|
+
integration paths:
|
|
5
|
+
|
|
6
|
+
* the OpenAI-compatible adapter, which needs OpenAI ``tools`` specs and a way to
|
|
7
|
+
route a tool call back to the owning server;
|
|
8
|
+
* the CLI adapters, which reuse the namespacing convention in their generated
|
|
9
|
+
native MCP config and ``--allowedTools`` lists.
|
|
10
|
+
|
|
11
|
+
Tools are namespaced ``mcp__<server>__<tool>`` — the same scheme the Claude CLI
|
|
12
|
+
uses — so a tool call reads identically across every adapter and in the ledger.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
PREFIX = "mcp"
|
|
20
|
+
SEP = "__"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def namespaced_name(server: str, tool: str) -> str:
|
|
24
|
+
"""``("github", "create_issue") -> "mcp__github__create_issue"``."""
|
|
25
|
+
return f"{PREFIX}{SEP}{server}{SEP}{tool}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_namespaced(name: str) -> tuple[str, str] | None:
|
|
29
|
+
"""Inverse of :func:`namespaced_name`. Returns ``(server, tool)`` or ``None`` if
|
|
30
|
+
``name`` is not an MCP-namespaced tool. The tool segment may itself contain the
|
|
31
|
+
separator, so we split off only the prefix and server."""
|
|
32
|
+
if not name.startswith(f"{PREFIX}{SEP}"):
|
|
33
|
+
return None
|
|
34
|
+
rest = name[len(PREFIX) + len(SEP):]
|
|
35
|
+
server, sep, tool = rest.partition(SEP)
|
|
36
|
+
if not sep or not server or not tool:
|
|
37
|
+
return None
|
|
38
|
+
return server, tool
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_mcp_tool(name: str) -> bool:
|
|
42
|
+
return parse_namespaced(name) is not None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class McpTool:
|
|
47
|
+
"""A tool advertised by an MCP server (transport-agnostic projection)."""
|
|
48
|
+
|
|
49
|
+
server: str
|
|
50
|
+
name: str
|
|
51
|
+
description: str = ""
|
|
52
|
+
input_schema: dict | None = None
|
|
53
|
+
|
|
54
|
+
def openai_spec(self) -> dict:
|
|
55
|
+
"""OpenAI ``tools`` function spec with a namespaced name."""
|
|
56
|
+
schema = self.input_schema or {"type": "object", "properties": {}}
|
|
57
|
+
return {
|
|
58
|
+
"type": "function",
|
|
59
|
+
"function": {
|
|
60
|
+
"name": namespaced_name(self.server, self.name),
|
|
61
|
+
"description": self.description or f"{self.name} (via MCP server {self.server})",
|
|
62
|
+
"parameters": schema,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def allowed(tool_name: str, allowlist: list[str] | None) -> bool:
|
|
68
|
+
"""A tool is allowed if the allowlist is empty/absent or names it (bare name)."""
|
|
69
|
+
return not allowlist or tool_name in allowlist
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def to_openai_specs(
|
|
73
|
+
tools: list[McpTool], allowlist_by_server: dict[str, list[str]] | None = None
|
|
74
|
+
) -> list[dict]:
|
|
75
|
+
"""Convert tools to OpenAI specs, applying each server's allowlist."""
|
|
76
|
+
allowlist_by_server = allowlist_by_server or {}
|
|
77
|
+
specs: list[dict] = []
|
|
78
|
+
for t in tools:
|
|
79
|
+
if allowed(t.name, allowlist_by_server.get(t.server)):
|
|
80
|
+
specs.append(t.openai_spec())
|
|
81
|
+
return specs
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from harness.memory.extract import extract_session_memory
|
|
2
|
+
from harness.memory.ingest import ingest_memory
|
|
3
|
+
from harness.memory.service import MemoryService, RetrievalResult, RetrievedItem
|
|
4
|
+
from harness.memory.summarize import summarize_session
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"MemoryService",
|
|
8
|
+
"RetrievalResult",
|
|
9
|
+
"RetrievedItem",
|
|
10
|
+
"extract_session_memory",
|
|
11
|
+
"ingest_memory",
|
|
12
|
+
"summarize_session",
|
|
13
|
+
]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Per-model context-window budgeting for the injected conversation context.
|
|
2
|
+
|
|
3
|
+
Adapters are stateless, so the orchestrator re-injects the session history into every
|
|
4
|
+
turn's prompt. This module trims that history to the **active model's** context window —
|
|
5
|
+
keeping recent turns verbatim and falling back to the session summary for older ones —
|
|
6
|
+
so small-window models don't overflow. Large (1M) models effectively never trim.
|
|
7
|
+
|
|
8
|
+
Token counts are estimated by a char heuristic (no tokenizer dependency); a reserve is
|
|
9
|
+
kept for the model's response, and the estimate intentionally over-counts so we trim a
|
|
10
|
+
little early rather than overflow.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
|
|
17
|
+
from harness.models.enums import AdapterKind
|
|
18
|
+
|
|
19
|
+
# Known model context windows (tokens), keyed by a normalized substring of the model
|
|
20
|
+
# name — first match wins, so list more specific keys first. Override per provider with
|
|
21
|
+
# ``params.max_context``.
|
|
22
|
+
MODEL_CONTEXT_WINDOWS: dict[str, int] = {
|
|
23
|
+
"claude-opus-4-8": 1_000_000,
|
|
24
|
+
"claude-sonnet-4-6": 1_000_000,
|
|
25
|
+
"claude-sonnet-4": 1_000_000,
|
|
26
|
+
"claude-fable-5": 1_000_000,
|
|
27
|
+
"claude-haiku": 200_000,
|
|
28
|
+
"gemini": 1_000_000, # 3-pro / 3.1-pro / 3.5-flash all 1M
|
|
29
|
+
"gemma": 131_072,
|
|
30
|
+
"gpt-4o": 128_000,
|
|
31
|
+
"gpt-4.1": 1_000_000,
|
|
32
|
+
"llama": 131_072,
|
|
33
|
+
"qwen": 131_072,
|
|
34
|
+
"mock": 8_192,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_KIND_DEFAULT: dict[AdapterKind, int] = {
|
|
38
|
+
AdapterKind.claude_code: 200_000,
|
|
39
|
+
AdapterKind.antigravity: 1_000_000,
|
|
40
|
+
AdapterKind.openai_local: 8_192,
|
|
41
|
+
AdapterKind.mock: 8_192,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_CHARS_PER_TOKEN = 3.5 # conservative: over-counts tokens so we trim early, never overflow
|
|
45
|
+
|
|
46
|
+
_HISTORY_HEADER = "## Session History"
|
|
47
|
+
_SUMMARY_HEADER = "## Earlier in this session (summary)"
|
|
48
|
+
_SEP = "\n\n"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def estimate_tokens(text: str) -> int:
|
|
52
|
+
return math.ceil(len(text) / _CHARS_PER_TOKEN) if text else 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def context_window(kind: AdapterKind, model: str | None, params: dict | None = None) -> int:
|
|
56
|
+
"""Resolve a provider's active-model context window (tokens):
|
|
57
|
+
explicit ``params.max_context`` → per-model registry → per-kind default."""
|
|
58
|
+
params = params or {}
|
|
59
|
+
mc = params.get("max_context")
|
|
60
|
+
if mc is not None:
|
|
61
|
+
try:
|
|
62
|
+
return int(mc)
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
pass
|
|
65
|
+
name = (model or "").lower()
|
|
66
|
+
for key, win in MODEL_CONTEXT_WINDOWS.items():
|
|
67
|
+
if key in name:
|
|
68
|
+
return win
|
|
69
|
+
return _KIND_DEFAULT.get(kind, 8_192)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _join_history(blocks: list[str], summary: str | None) -> str:
|
|
73
|
+
body = _SEP.join(blocks)
|
|
74
|
+
if summary:
|
|
75
|
+
if body:
|
|
76
|
+
return f"{_SUMMARY_HEADER}\n{summary}{_SEP}{_HISTORY_HEADER}\n{body}"
|
|
77
|
+
return f"{_SUMMARY_HEADER}\n{summary}"
|
|
78
|
+
return f"{_HISTORY_HEADER}\n{body}" if body else ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_memory_context(
|
|
82
|
+
semantic_block: str,
|
|
83
|
+
transcript_blocks: list[str],
|
|
84
|
+
summary: str | None,
|
|
85
|
+
*,
|
|
86
|
+
window: int,
|
|
87
|
+
message_tokens: int = 0,
|
|
88
|
+
reserve_tokens: int | None = None,
|
|
89
|
+
) -> str:
|
|
90
|
+
"""Assemble the injected context (semantic memory + session history), trimmed to
|
|
91
|
+
``window``. Keeps the newest transcript blocks verbatim; if older blocks must be
|
|
92
|
+
dropped, prepends the session ``summary``. When everything fits, the full transcript
|
|
93
|
+
is returned UNCHANGED — so 1M-window models never trim."""
|
|
94
|
+
reserve = reserve_tokens if reserve_tokens is not None else min(window // 5, 32_000)
|
|
95
|
+
budget = window - reserve - message_tokens - estimate_tokens(semantic_block)
|
|
96
|
+
|
|
97
|
+
full = _join_history(transcript_blocks, None)
|
|
98
|
+
if not transcript_blocks or budget <= 0 or estimate_tokens(full) <= budget:
|
|
99
|
+
out = full # fits (or can't meaningfully budget) → no trim
|
|
100
|
+
else:
|
|
101
|
+
overhead = estimate_tokens(f"{_SUMMARY_HEADER}\n{summary}") if summary else 0
|
|
102
|
+
overhead += estimate_tokens(f"\n{_HISTORY_HEADER}\n")
|
|
103
|
+
kept: list[str] = []
|
|
104
|
+
used = overhead
|
|
105
|
+
for block in reversed(transcript_blocks):
|
|
106
|
+
t = estimate_tokens(block + _SEP)
|
|
107
|
+
if used + t > budget and kept:
|
|
108
|
+
break
|
|
109
|
+
kept.insert(0, block)
|
|
110
|
+
used += t
|
|
111
|
+
dropped = len(kept) < len(transcript_blocks)
|
|
112
|
+
out = _join_history(kept, summary if (dropped and summary) else None)
|
|
113
|
+
|
|
114
|
+
if not out:
|
|
115
|
+
return semantic_block
|
|
116
|
+
return f"{semantic_block}{_SEP}{out}".strip() if semantic_block else out
|
harness/memory/embed.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Pluggable embedding providers.
|
|
2
|
+
|
|
3
|
+
- ``HashEmbedder``: deterministic, offline, dependency-free. Default for dev/CI so
|
|
4
|
+
the whole memory pipeline works without a model server. Not semantically strong,
|
|
5
|
+
but stable and fast — good enough to validate retrieval plumbing.
|
|
6
|
+
- ``OpenAICompatibleEmbedder``: any ``/v1/embeddings`` endpoint (Ollama, LM Studio,
|
|
7
|
+
vLLM, LocalAI). Used in real deployments.
|
|
8
|
+
- ``FastEmbedEmbedder``: offline, local-model embedder via ``fastembed``.
|
|
9
|
+
Install the optional extra to enable: ``pip install 'ags-cli[embed]'``.
|
|
10
|
+
|
|
11
|
+
Resolution for ``embedding_provider = "auto"`` (the default):
|
|
12
|
+
1. ``fastembed`` importable → ``FastEmbedEmbedder`` (BAAI/bge-small-en-v1.5, dim 384)
|
|
13
|
+
2. A provider config named ``local`` with an enabled entry + ``base_url``
|
|
14
|
+
→ ``OpenAICompatibleEmbedder`` with ``base_url`` stripped of any trailing
|
|
15
|
+
slash; ``.embed()`` appends ``/embeddings`` at call time, so the final
|
|
16
|
+
request URL is ``{base_url}/embeddings``
|
|
17
|
+
3. ``HashEmbedder`` + one log warning about the degraded path.
|
|
18
|
+
|
|
19
|
+
Explicit values (``hash`` / ``openai_compatible`` / ``fastembed``) bypass resolution.
|
|
20
|
+
``fastembed`` without the package → ``RuntimeError``.
|
|
21
|
+
|
|
22
|
+
The effective dimension comes from the chosen embedder's ``.dim`` attribute;
|
|
23
|
+
callers should not rely on ``settings.embedding_dim`` when using auto-resolution.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import hashlib
|
|
30
|
+
import importlib.util
|
|
31
|
+
import logging
|
|
32
|
+
import math
|
|
33
|
+
from typing import Protocol
|
|
34
|
+
|
|
35
|
+
import httpx
|
|
36
|
+
|
|
37
|
+
from harness.settings import Settings, get_settings
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Embedder(Protocol):
|
|
43
|
+
model: str
|
|
44
|
+
async def embed(self, texts: list[str]) -> list[list[float]]: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HashEmbedder:
|
|
48
|
+
"""Deterministic hashing embedder: feature-hash tokens into ``dim`` buckets,
|
|
49
|
+
then L2-normalize. Cosine similarity is meaningful for lexical overlap."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, dim: int) -> None:
|
|
52
|
+
self.dim = dim
|
|
53
|
+
self.model = f"hash-{dim}"
|
|
54
|
+
|
|
55
|
+
async def embed(self, texts: list[str]) -> list[list[float]]:
|
|
56
|
+
return [self._embed_one(t) for t in texts]
|
|
57
|
+
|
|
58
|
+
def _embed_one(self, text: str) -> list[float]:
|
|
59
|
+
vec = [0.0] * self.dim
|
|
60
|
+
for token in text.lower().split():
|
|
61
|
+
digest = hashlib.sha1(token.encode()).digest()
|
|
62
|
+
idx = int.from_bytes(digest[:4], "big") % self.dim
|
|
63
|
+
sign = 1.0 if digest[4] & 1 else -1.0
|
|
64
|
+
vec[idx] += sign
|
|
65
|
+
norm = math.sqrt(sum(v * v for v in vec)) or 1.0
|
|
66
|
+
return [v / norm for v in vec]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OpenAICompatibleEmbedder:
|
|
70
|
+
def __init__(self, base_url: str, api_key: str, model: str, dim: int) -> None:
|
|
71
|
+
self.base_url = base_url.rstrip("/")
|
|
72
|
+
self.api_key = api_key
|
|
73
|
+
self.model = model
|
|
74
|
+
self.dim = dim
|
|
75
|
+
|
|
76
|
+
async def embed(self, texts: list[str]) -> list[list[float]]:
|
|
77
|
+
headers = {"Content-Type": "application/json"}
|
|
78
|
+
if self.api_key:
|
|
79
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
80
|
+
# Fast-fail timeout: protects callers from slow-network hangs on unreachable endpoints.
|
|
81
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client:
|
|
82
|
+
resp = await client.post(
|
|
83
|
+
f"{self.base_url}/embeddings",
|
|
84
|
+
headers=headers,
|
|
85
|
+
json={"model": self.model, "input": texts},
|
|
86
|
+
)
|
|
87
|
+
resp.raise_for_status()
|
|
88
|
+
data = resp.json()["data"]
|
|
89
|
+
return [item["embedding"] for item in data]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class FastEmbedEmbedder:
|
|
93
|
+
"""Local offline embedder backed by ``fastembed``.
|
|
94
|
+
|
|
95
|
+
The ``fastembed`` package is an optional dependency — install with
|
|
96
|
+
``pip install 'ags-cli[embed]'`` or ``uv tool install 'ags-cli[embed]'``.
|
|
97
|
+
The import is deferred to ``__init__`` so the class can be defined and
|
|
98
|
+
imported without the package installed (used for branch selection in
|
|
99
|
+
``build_embedder``).
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
MODEL = "BAAI/bge-small-en-v1.5"
|
|
103
|
+
DIM = 384
|
|
104
|
+
|
|
105
|
+
def __init__(self, model: str = MODEL) -> None:
|
|
106
|
+
from fastembed import TextEmbedding # lazy import — package optional
|
|
107
|
+
self._te = TextEmbedding(model_name=model)
|
|
108
|
+
self.model = model
|
|
109
|
+
self.dim = self.DIM
|
|
110
|
+
|
|
111
|
+
async def embed(self, texts: list[str]) -> list[list[float]]:
|
|
112
|
+
"""Embed ``texts`` using the fastembed model (runs sync model in a thread)."""
|
|
113
|
+
def _sync_embed() -> list[list[float]]:
|
|
114
|
+
return [v.tolist() for v in self._te.embed(texts)]
|
|
115
|
+
|
|
116
|
+
return await asyncio.to_thread(_sync_embed)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Factory
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def build_embedder(
|
|
124
|
+
settings: Settings,
|
|
125
|
+
provider_configs: dict | None = None,
|
|
126
|
+
) -> Embedder:
|
|
127
|
+
"""Build the embedder dictated by ``settings.embedding_provider``.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
settings:
|
|
132
|
+
Application settings. ``embedding_provider`` controls selection.
|
|
133
|
+
provider_configs:
|
|
134
|
+
Optional dict of provider config dicts keyed by provider name.
|
|
135
|
+
Only consulted when ``embedding_provider == "auto"`` for the
|
|
136
|
+
``local`` provider fallback. Expected shape::
|
|
137
|
+
|
|
138
|
+
{
|
|
139
|
+
"local": {
|
|
140
|
+
"name": "local",
|
|
141
|
+
"enabled": True,
|
|
142
|
+
"base_url": "http://localhost:11434/v1",
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
"""
|
|
146
|
+
provider = settings.embedding_provider
|
|
147
|
+
|
|
148
|
+
# --- Explicit "hash" --------------------------------------------------
|
|
149
|
+
if provider == "hash":
|
|
150
|
+
return HashEmbedder(settings.embedding_dim)
|
|
151
|
+
|
|
152
|
+
# --- Explicit "openai_compatible" -------------------------------------
|
|
153
|
+
if provider == "openai_compatible":
|
|
154
|
+
return OpenAICompatibleEmbedder(
|
|
155
|
+
base_url=settings.embedding_base_url,
|
|
156
|
+
api_key=settings.embedding_api_key,
|
|
157
|
+
model=settings.embedding_model,
|
|
158
|
+
dim=settings.embedding_dim,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# --- Explicit "fastembed" ---------------------------------------------
|
|
162
|
+
if provider == "fastembed":
|
|
163
|
+
if importlib.util.find_spec("fastembed") is None:
|
|
164
|
+
raise RuntimeError(
|
|
165
|
+
"embedding_provider='fastembed' but the fastembed package is not "
|
|
166
|
+
"installed. Install it with: pip install 'ags-cli[embed]'"
|
|
167
|
+
)
|
|
168
|
+
# Explicit user choice — they own any migration; honour it.
|
|
169
|
+
return FastEmbedEmbedder()
|
|
170
|
+
|
|
171
|
+
# --- "auto" resolution ------------------------------------------------
|
|
172
|
+
# (a) fastembed importable → prefer it.
|
|
173
|
+
if importlib.util.find_spec("fastembed") is not None:
|
|
174
|
+
return FastEmbedEmbedder()
|
|
175
|
+
|
|
176
|
+
# (b) enabled local provider with a base_url
|
|
177
|
+
if provider_configs:
|
|
178
|
+
local_cfg = provider_configs.get("local")
|
|
179
|
+
if (
|
|
180
|
+
local_cfg
|
|
181
|
+
and local_cfg.get("enabled", False)
|
|
182
|
+
and local_cfg.get("base_url")
|
|
183
|
+
):
|
|
184
|
+
base_url = local_cfg["base_url"]
|
|
185
|
+
return OpenAICompatibleEmbedder(
|
|
186
|
+
base_url=base_url.rstrip("/"),
|
|
187
|
+
api_key=local_cfg.get("api_key", ""),
|
|
188
|
+
model=settings.embedding_model,
|
|
189
|
+
dim=settings.embedding_dim,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# (c) last resort: hash embedder with a degradation warning
|
|
193
|
+
log.warning(
|
|
194
|
+
"semantic memory degraded: hash embedder in use — pip install 'ags-cli[embed]'"
|
|
195
|
+
)
|
|
196
|
+
return HashEmbedder(settings.embedding_dim)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_embedder(settings: Settings | None = None) -> Embedder:
|
|
200
|
+
"""Backwards-compatible accessor.
|
|
201
|
+
|
|
202
|
+
Resolves ``provider_configs`` from the settings config() so that the
|
|
203
|
+
``local`` provider's ``base_url`` is available for auto-resolution.
|
|
204
|
+
Callers that need explicit provider_configs should call ``build_embedder``
|
|
205
|
+
directly.
|
|
206
|
+
"""
|
|
207
|
+
settings = settings or get_settings()
|
|
208
|
+
# Extract a provider_configs dict from the loaded config (if any).
|
|
209
|
+
provider_configs: dict | None = None
|
|
210
|
+
try:
|
|
211
|
+
cfg = settings.config()
|
|
212
|
+
providers = cfg.get("providers") or []
|
|
213
|
+
if providers:
|
|
214
|
+
provider_configs = {p["name"]: p for p in providers if p.get("name")}
|
|
215
|
+
except Exception:
|
|
216
|
+
pass # config file absent in tests / minimal environments
|
|
217
|
+
return build_embedder(settings, provider_configs=provider_configs)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Memory extraction pipeline.
|
|
2
|
+
|
|
3
|
+
After a session completes, distill its runs into durable, provider-independent
|
|
4
|
+
memory: each succeeded run's output becomes an **episodic** memory item (what was
|
|
5
|
+
produced, by whom, with outcome), and the canonical session summary becomes a
|
|
6
|
+
**summary** item. Secrets are redacted by the ingest layer before storage/embed;
|
|
7
|
+
dedup keeps re-runs idempotent. This is what lets a later session — on any
|
|
8
|
+
provider — retrieve "what we already concluded" via the shared memory seam.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
from sqlalchemy import select
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
|
|
18
|
+
from harness.memory.ingest import ingest_memory
|
|
19
|
+
from harness.memory.namespaces import provider_ns, workspace_ns
|
|
20
|
+
from harness.models.enums import MemoryScope, MemoryType, RunStatus
|
|
21
|
+
from harness.models.run import Run
|
|
22
|
+
from harness.models.session import Session
|
|
23
|
+
from harness.models.workspace import Workspace
|
|
24
|
+
from harness.observability.logging import get_logger
|
|
25
|
+
|
|
26
|
+
log = get_logger("memory-extract")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def extract_session_memory(db: AsyncSession, session_id: uuid.UUID) -> int:
|
|
30
|
+
"""Ingest episodic + summary memory for a completed session. Returns the number
|
|
31
|
+
of memory items written (existing duplicates are not double-counted)."""
|
|
32
|
+
sess = (
|
|
33
|
+
await db.execute(select(Session).where(Session.id == session_id))
|
|
34
|
+
).scalar_one()
|
|
35
|
+
workspace = (
|
|
36
|
+
await db.execute(select(Workspace).where(Workspace.id == sess.workspace_id))
|
|
37
|
+
).scalar_one()
|
|
38
|
+
runs = (
|
|
39
|
+
await db.execute(select(Run).where(Run.session_id == session_id))
|
|
40
|
+
).scalars().all()
|
|
41
|
+
|
|
42
|
+
written = 0
|
|
43
|
+
objective_tag = sess.canonical_objective[:40]
|
|
44
|
+
|
|
45
|
+
for run in runs:
|
|
46
|
+
if run.status != RunStatus.succeeded or not run.final_output:
|
|
47
|
+
continue
|
|
48
|
+
item = await ingest_memory(
|
|
49
|
+
db,
|
|
50
|
+
namespace=provider_ns(workspace.slug, run.adapter_kind.value),
|
|
51
|
+
scope=MemoryScope.workspace,
|
|
52
|
+
mem_type=MemoryType.episodic,
|
|
53
|
+
content=(
|
|
54
|
+
f"[{run.adapter_kind.value}/{run.role.value}] for objective "
|
|
55
|
+
f"'{objective_tag}':\n{run.final_output}"
|
|
56
|
+
),
|
|
57
|
+
tags=["episodic", run.adapter_kind.value, objective_tag],
|
|
58
|
+
source_run_id=run.id,
|
|
59
|
+
)
|
|
60
|
+
written += 1 if item is not None else 0
|
|
61
|
+
|
|
62
|
+
if sess.summary:
|
|
63
|
+
await ingest_memory(
|
|
64
|
+
db,
|
|
65
|
+
namespace=workspace_ns(workspace.slug),
|
|
66
|
+
scope=MemoryScope.workspace,
|
|
67
|
+
mem_type=MemoryType.summary,
|
|
68
|
+
content=sess.summary,
|
|
69
|
+
tags=["session_summary", objective_tag],
|
|
70
|
+
)
|
|
71
|
+
written += 1
|
|
72
|
+
|
|
73
|
+
log.info("memory_extracted", session_id=str(session_id), items=written)
|
|
74
|
+
return written
|
harness/memory/ingest.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Memory ingestion pipeline: redact → dedup → store item → chunk → embed.
|
|
2
|
+
|
|
3
|
+
Idempotent on ``(namespace, dedup_hash)`` — re-ingesting identical content is a
|
|
4
|
+
no-op. Secrets are redacted before anything is stored or embedded."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import select
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
|
|
14
|
+
from harness.memory.embed import Embedder, get_embedder
|
|
15
|
+
from harness.memory.namespaces import ttl_for
|
|
16
|
+
from harness.memory.redact import redact
|
|
17
|
+
from harness.memory.sqlite_vec_backend import SqliteVecIndex
|
|
18
|
+
from harness.models.enums import MemoryScope, MemoryType
|
|
19
|
+
from harness.models.memory import MemoryEmbedding, MemoryItem
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _dedup_hash(namespace: str, content: str) -> str:
|
|
23
|
+
return hashlib.sha256(f"{namespace}\x00{content}".encode()).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _chunk(text: str, *, size: int = 800, overlap: int = 100) -> list[str]:
|
|
27
|
+
if len(text) <= size:
|
|
28
|
+
return [text]
|
|
29
|
+
chunks, start = [], 0
|
|
30
|
+
while start < len(text):
|
|
31
|
+
chunks.append(text[start : start + size])
|
|
32
|
+
start += size - overlap
|
|
33
|
+
return chunks
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def ingest_memory(
|
|
37
|
+
session: AsyncSession,
|
|
38
|
+
*,
|
|
39
|
+
namespace: str,
|
|
40
|
+
scope: MemoryScope,
|
|
41
|
+
mem_type: MemoryType,
|
|
42
|
+
content: str,
|
|
43
|
+
tags: list[str] | None = None,
|
|
44
|
+
source_run_id: uuid.UUID | None = None,
|
|
45
|
+
embedder: Embedder | None = None,
|
|
46
|
+
ttl_hours_session: int | None = 72,
|
|
47
|
+
ttl_hours_workspace: int | None = None,
|
|
48
|
+
) -> MemoryItem:
|
|
49
|
+
"""Ingest one memory item. Returns the existing item if a duplicate."""
|
|
50
|
+
redacted_content, was_redacted = redact(content)
|
|
51
|
+
dedup = _dedup_hash(namespace, redacted_content)
|
|
52
|
+
|
|
53
|
+
existing = (
|
|
54
|
+
await session.execute(
|
|
55
|
+
select(MemoryItem).where(
|
|
56
|
+
MemoryItem.namespace == namespace, MemoryItem.dedup_hash == dedup
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
).scalar_one_or_none()
|
|
60
|
+
if existing is not None:
|
|
61
|
+
return existing
|
|
62
|
+
|
|
63
|
+
# Embed BEFORE touching the DB — network I/O must never run inside the
|
|
64
|
+
# SQLite write transaction (holds the write lock and starves concurrent writers).
|
|
65
|
+
embedder = embedder or get_embedder()
|
|
66
|
+
chunks = _chunk(redacted_content)
|
|
67
|
+
vectors = await embedder.embed(chunks)
|
|
68
|
+
|
|
69
|
+
item = MemoryItem(
|
|
70
|
+
namespace=namespace,
|
|
71
|
+
scope=scope,
|
|
72
|
+
type=mem_type,
|
|
73
|
+
content=redacted_content,
|
|
74
|
+
tags=tags or [],
|
|
75
|
+
source_run_id=source_run_id,
|
|
76
|
+
dedup_hash=dedup,
|
|
77
|
+
redacted=was_redacted,
|
|
78
|
+
ttl_expires_at=ttl_for(
|
|
79
|
+
scope,
|
|
80
|
+
session_volatile_hours=ttl_hours_session,
|
|
81
|
+
workspace_durable_hours=ttl_hours_workspace,
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
session.add(item)
|
|
85
|
+
await session.flush() # assign item.id
|
|
86
|
+
|
|
87
|
+
vec_loaded = await SqliteVecIndex.loaded_for_session(session)
|
|
88
|
+
if vec_loaded:
|
|
89
|
+
await SqliteVecIndex.ensure_table(session, embedder.dim)
|
|
90
|
+
|
|
91
|
+
for idx, (chunk, vec) in enumerate(zip(chunks, vectors, strict=True)):
|
|
92
|
+
session.add(
|
|
93
|
+
MemoryEmbedding(
|
|
94
|
+
memory_item_id=item.id,
|
|
95
|
+
chunk_idx=idx,
|
|
96
|
+
model=embedder.model,
|
|
97
|
+
embedding=vec,
|
|
98
|
+
meta={"chars": len(chunk)},
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
if vec_loaded:
|
|
102
|
+
# Mirror into the vec0 ANN table; item.id is a UUID, stored as str.
|
|
103
|
+
await SqliteVecIndex.upsert(session, str(item.id), idx, vec)
|
|
104
|
+
|
|
105
|
+
await session.flush()
|
|
106
|
+
return item
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Memory namespaces and TTL policy.
|
|
2
|
+
|
|
3
|
+
A namespace is a hierarchical key that enforces isolation and enables targeted
|
|
4
|
+
retrieval. Format:
|
|
5
|
+
|
|
6
|
+
ws/<workspace_slug>[/project/<name>][/provider/<name>][/session/<id>]
|
|
7
|
+
|
|
8
|
+
Retrieval is namespace-bounded by default — no implicit cross-project leakage.
|
|
9
|
+
Workspace-scoped durable memory uses the ``ws/<slug>`` prefix; session-scoped
|
|
10
|
+
volatile memory appends ``/session/<id>`` and carries a TTL.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from datetime import UTC, datetime, timedelta
|
|
16
|
+
|
|
17
|
+
from harness.models.enums import MemoryScope
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def workspace_ns(slug: str, *, project: str | None = None) -> str:
|
|
21
|
+
ns = f"ws/{slug}"
|
|
22
|
+
if project:
|
|
23
|
+
ns += f"/project/{project}"
|
|
24
|
+
return ns
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def session_ns(slug: str, session_id: str, *, project: str | None = None) -> str:
|
|
28
|
+
return f"{workspace_ns(slug, project=project)}/session/{session_id}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def provider_ns(slug: str, provider: str, *, project: str | None = None) -> str:
|
|
32
|
+
return f"{workspace_ns(slug, project=project)}/provider/{provider}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def namespace_prefixes_for_retrieval(
|
|
36
|
+
slug: str, *, session_id: str | None = None, project: str | None = None
|
|
37
|
+
) -> list[str]:
|
|
38
|
+
"""Namespaces a retrieval should search: the session (if any) plus the
|
|
39
|
+
workspace, most specific first. Never crosses into another workspace."""
|
|
40
|
+
prefixes = [workspace_ns(slug, project=project)]
|
|
41
|
+
if session_id:
|
|
42
|
+
prefixes.insert(0, session_ns(slug, session_id, project=project))
|
|
43
|
+
return prefixes
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ttl_for(
|
|
47
|
+
scope: MemoryScope,
|
|
48
|
+
*,
|
|
49
|
+
session_volatile_hours: int | None = 72,
|
|
50
|
+
workspace_durable_hours: int | None = None,
|
|
51
|
+
) -> datetime | None:
|
|
52
|
+
"""Compute an absolute expiry from policy. Workspace memory is durable
|
|
53
|
+
(no expiry) by default; session memory is volatile."""
|
|
54
|
+
hours = session_volatile_hours if scope == MemoryScope.session else workspace_durable_hours
|
|
55
|
+
if hours is None:
|
|
56
|
+
return None
|
|
57
|
+
return datetime.now(UTC) + timedelta(hours=hours)
|