licos-agent-runtime 0.3.1__tar.gz → 0.3.2__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 (79) hide show
  1. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/PKG-INFO +1 -1
  2. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/pyproject.toml +1 -1
  3. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/app.py +25 -0
  4. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/harness_config.py +5 -0
  5. licos_agent_runtime-0.3.2/src/licos_agent_runtime/project_history.py +69 -0
  6. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/service.py +16 -2
  7. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/session_memory.py +43 -7
  8. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_harness.py +102 -1
  9. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/.gitignore +0 -0
  10. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/README.md +0 -0
  11. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/__init__.py +0 -0
  12. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/__main__.py +0 -0
  13. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/agent_canvas.py +0 -0
  14. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/agent_config.py +0 -0
  15. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/agent_graph.py +0 -0
  16. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/agent_input.py +0 -0
  17. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/attachments.py +0 -0
  18. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/context.py +0 -0
  19. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/credit_availability.py +0 -0
  20. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/errors.py +0 -0
  21. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/event_store.py +0 -0
  22. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/event_subscriptions.py +0 -0
  23. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/graph_contract.py +0 -0
  24. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/graph_loader.py +0 -0
  25. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/harness_approval.py +0 -0
  26. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/harness_execution.py +0 -0
  27. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/harness_planning.py +0 -0
  28. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/harness_verification.py +0 -0
  29. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/logging.py +0 -0
  30. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/model_identity.py +0 -0
  31. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/model_usage.py +0 -0
  32. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/observability.py +0 -0
  33. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/openai.py +0 -0
  34. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/output_files.py +0 -0
  35. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/parser.py +0 -0
  36. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/payload.py +0 -0
  37. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/project_agent.py +0 -0
  38. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/project_files.py +0 -0
  39. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/project_skills.py +0 -0
  40. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/run_config.py +0 -0
  41. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/runtime_config.py +0 -0
  42. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/session_files.py +0 -0
  43. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/stream_protocol.py +0 -0
  44. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/stream_runner.py +0 -0
  45. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/telemetry.py +0 -0
  46. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/trace_records.py +0 -0
  47. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/verify.py +0 -0
  48. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/workflow_canvas.py +0 -0
  49. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/workflow_protocol.py +0 -0
  50. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_agent_config.py +0 -0
  51. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_agent_graph.py +0 -0
  52. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_app_cors.py +0 -0
  53. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_attachment_inputs.py +0 -0
  54. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_context.py +0 -0
  55. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_credit_availability.py +0 -0
  56. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_credit_availability_routes.py +0 -0
  57. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_deployment_layout.py +0 -0
  58. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_event_delivery_api.py +0 -0
  59. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_event_subscriptions.py +0 -0
  60. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_execution_errors.py +0 -0
  61. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_graph_contract.py +0 -0
  62. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_graph_loader.py +0 -0
  63. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_model_identity.py +0 -0
  64. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_model_usage.py +0 -0
  65. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_observability.py +0 -0
  66. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_openai.py +0 -0
  67. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_parser.py +0 -0
  68. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_payload.py +0 -0
  69. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_project_files.py +0 -0
  70. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_project_skills.py +0 -0
  71. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_run_config.py +0 -0
  72. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_runtime_config.py +0 -0
  73. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_service_stream_protocol.py +0 -0
  74. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_static_assets.py +0 -0
  75. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_stream_protocol.py +0 -0
  76. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_stream_runner.py +0 -0
  77. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_trace_records.py +0 -0
  78. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_verify.py +0 -0
  79. {licos_agent_runtime-0.3.1 → licos_agent_runtime-0.3.2}/tests/test_workflow_protocol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-agent-runtime
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: LICOS Python agent runtime for LangGraph/LangChain projects
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: fastapi<1,>=0.121
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-agent-runtime"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "LICOS Python agent runtime for LangGraph/LangChain projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -469,6 +469,31 @@ def create_app(service: GraphService | None = None) -> FastAPI:
469
469
  del request
470
470
  return runtime_service.agent_config_info()
471
471
 
472
+ @app.get("/agent/history/{session_id}")
473
+ async def http_agent_history(
474
+ session_id: str,
475
+ request: Request,
476
+ limit: int = Query(20, ge=1, le=100),
477
+ offset: int = Query(0, ge=0),
478
+ ) -> dict[str, Any]:
479
+ ctx = new_context(method="agent_history", headers=request.headers)
480
+ try:
481
+ return await runtime_service.conversation_history(session_id, ctx, limit, offset)
482
+ except LookupError as exc:
483
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
484
+ except ValueError as exc:
485
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
486
+
487
+ @app.delete("/agent/history/{session_id}")
488
+ async def http_agent_history_delete(session_id: str, request: Request) -> dict[str, Any]:
489
+ ctx = new_context(method="agent_history_delete", headers=request.headers)
490
+ try:
491
+ return await runtime_service.delete_conversation_history(session_id, ctx)
492
+ except LookupError as exc:
493
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
494
+ except ValueError as exc:
495
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
496
+
472
497
  @app.get("/run_records")
473
498
  async def http_run_records(
474
499
  request: Request,
@@ -25,6 +25,10 @@ class MemorySettings(_StrictModel):
25
25
  idle_ttl_seconds: int = Field(default=3600, ge=1)
26
26
 
27
27
 
28
+ class HistorySettings(_StrictModel):
29
+ enabled: bool = False
30
+
31
+
28
32
  class PlanningSettings(_StrictModel):
29
33
  mode: Literal["off", "auto", "always"] = "auto"
30
34
 
@@ -77,6 +81,7 @@ class ThinkingSettings(_StrictModel):
77
81
  class HarnessSettings(_StrictModel):
78
82
  version: Literal[1]
79
83
  memory: MemorySettings = Field(default_factory=MemorySettings)
84
+ history: HistorySettings = Field(default_factory=HistorySettings)
80
85
  planning: PlanningSettings = Field(default_factory=PlanningSettings)
81
86
  context: ContextSettings = Field(default_factory=ContextSettings)
82
87
  execution: ExecutionSettings = Field(default_factory=ExecutionSettings)
@@ -0,0 +1,69 @@
1
+ """Opt-in completed-turn history stored in the current project database."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from typing import Any
6
+
7
+ from licos_platform_sdk import database
8
+
9
+
10
+ TABLE = "agent_conversation_turns"
11
+
12
+
13
+ def _scope(user_id: str, session_id: str) -> list[dict[str, str]]:
14
+ return [
15
+ {"field": "user_id", "op": "eq", "value": user_id},
16
+ {"field": "session_id", "op": "eq", "value": session_id},
17
+ ]
18
+
19
+
20
+ class ProjectHistory:
21
+ async def recent(self, user_id: str, session_id: str, limit: int) -> list[tuple[str, str]]:
22
+ result = await asyncio.to_thread(
23
+ database.query,
24
+ TABLE,
25
+ select=["user_text", "assistant_text"],
26
+ filters=_scope(user_id, session_id),
27
+ order_by=[
28
+ {"field": "created_at", "direction": "desc"},
29
+ {"field": "id", "direction": "desc"},
30
+ ],
31
+ limit=limit,
32
+ )
33
+ return [(row["user_text"], row["assistant_text"]) for row in reversed(result["data"])]
34
+
35
+ async def save(
36
+ self, user_id: str, session_id: str, run_id: str, user_text: str, assistant_text: str
37
+ ) -> None:
38
+ await asyncio.to_thread(
39
+ database.insert,
40
+ TABLE,
41
+ row={
42
+ "id": run_id,
43
+ "user_id": user_id,
44
+ "session_id": session_id,
45
+ "user_text": user_text,
46
+ "assistant_text": assistant_text,
47
+ },
48
+ returning=False,
49
+ )
50
+
51
+ async def page(self, user_id: str, session_id: str, limit: int, offset: int) -> dict[str, Any]:
52
+ return await asyncio.to_thread(
53
+ database.query,
54
+ TABLE,
55
+ select=["id", "user_text", "assistant_text", "created_at"],
56
+ filters=_scope(user_id, session_id),
57
+ order_by=[
58
+ {"field": "created_at", "direction": "desc"},
59
+ {"field": "id", "direction": "desc"},
60
+ ],
61
+ limit=limit,
62
+ offset=offset,
63
+ count=True,
64
+ )
65
+
66
+ async def delete(self, user_id: str, session_id: str) -> dict[str, Any]:
67
+ return await asyncio.to_thread(
68
+ database.delete, TABLE, filters=_scope(user_id, session_id), returning=False
69
+ )
@@ -300,7 +300,7 @@ class GraphService:
300
300
  result = await asyncio.wait_for(
301
301
  invocation, timeout=snapshot.harness.execution.max_duration_seconds
302
302
  ) if snapshot and snapshot.harness else await invocation
303
- turn.commit(extract_answer(result))
303
+ await turn.commit(extract_answer(result))
304
304
  else:
305
305
  payload = materialize_input_files(payload)
306
306
  recorder.set_root_input(payload)
@@ -511,7 +511,7 @@ class GraphService:
511
511
  else:
512
512
  await consume()
513
513
  if answers:
514
- turn.commit("".join(answers) if run_opt.stream_mode == "messages" else answers[-1])
514
+ await turn.commit("".join(answers) if run_opt.stream_mode == "messages" else answers[-1])
515
515
  except BaseException as exc:
516
516
  await queue.put(exc)
517
517
  finally:
@@ -906,6 +906,20 @@ class GraphService:
906
906
  **read_agent_config(),
907
907
  }
908
908
 
909
+ async def conversation_history(
910
+ self, session_id: str, ctx: Context, limit: int, offset: int
911
+ ) -> dict[str, Any]:
912
+ snapshot = self._agent_snapshot(ctx)
913
+ if snapshot is None or snapshot.harness is None or not snapshot.harness.history.enabled:
914
+ raise LookupError("Project database conversation history is disabled")
915
+ return await self._session_memory.history_page(ctx, snapshot.harness, session_id, limit, offset)
916
+
917
+ async def delete_conversation_history(self, session_id: str, ctx: Context) -> dict[str, Any]:
918
+ snapshot = self._agent_snapshot(ctx)
919
+ if snapshot is None or snapshot.harness is None or not snapshot.harness.history.enabled:
920
+ raise LookupError("Project database conversation history is disabled")
921
+ return await self._session_memory.history_delete(ctx, snapshot.harness, session_id)
922
+
909
923
  def agent_canvas(self) -> dict[str, Any]:
910
924
  if is_agent_project():
911
925
  return agent_canvas_response()
@@ -12,6 +12,7 @@ from typing import Any, AsyncIterator
12
12
 
13
13
  from .context import Context
14
14
  from .harness_config import HarnessSettings
15
+ from .project_history import ProjectHistory
15
16
  from .session_files import session_user_id
16
17
  from .stream_protocol import extract_prompt_text
17
18
 
@@ -34,10 +35,17 @@ class MemoryTurn:
34
35
  user_text: str | None = None
35
36
  _session: _Session | None = None
36
37
  _window: int = 0
38
+ _history: ProjectHistory | None = None
39
+ _user_id: str = ""
40
+ _session_id: str = ""
41
+ _run_id: str = ""
37
42
 
38
- def commit(self, answer: str) -> None:
43
+ async def commit(self, answer: str) -> None:
39
44
  if self._session is None or not self.user_text or not answer.strip():
40
45
  return
46
+ if self._history is not None:
47
+ await self._history.save(self._user_id, self._session_id, self._run_id, self.user_text, answer)
48
+ return
41
49
  self._session.turns.append((self.user_text, answer))
42
50
  while len(self._session.turns) > self._window:
43
51
  self._session.turns.popleft()
@@ -48,13 +56,13 @@ class SessionMemory:
48
56
  def __init__(self) -> None:
49
57
  self._sessions: OrderedDict[SessionKey, _Session] = OrderedDict()
50
58
  self._generation = 0
59
+ self._history = ProjectHistory()
51
60
 
52
61
  @staticmethod
53
62
  def _eligible(request: dict[str, Any], settings: HarnessSettings | None) -> bool:
54
63
  return bool(
55
64
  settings
56
- and settings.memory.enabled
57
- and settings.memory.recent_turns
65
+ and ((settings.memory.enabled and settings.memory.recent_turns) or settings.history.enabled)
58
66
  and isinstance(request.get("session_id"), str)
59
67
  and request["session_id"].strip()
60
68
  and not request.get("messages")
@@ -107,11 +115,21 @@ class SessionMemory:
107
115
  if session.generation != self._generation or now - session.touched > settings.memory.idle_ttl_seconds:
108
116
  session.turns.clear()
109
117
  session.generation = self._generation
110
- while len(session.turns) > settings.memory.recent_turns:
111
- session.turns.popleft()
118
+ if settings.history.enabled:
119
+ user_id = session_user_id(ctx)
120
+ session.turns.clear()
121
+ turns = (
122
+ await self._history.recent(user_id, session_id, settings.memory.recent_turns)
123
+ if settings.memory.enabled and settings.memory.recent_turns else []
124
+ )
125
+ else:
126
+ user_id = ""
127
+ while len(session.turns) > settings.memory.recent_turns:
128
+ session.turns.popleft()
129
+ turns = session.turns if settings.memory.enabled else []
112
130
  session.touched = now
113
131
  history: list[dict[str, str]] = []
114
- for user, assistant in session.turns:
132
+ for user, assistant in turns:
115
133
  history.extend(({"role": "user", "content": user}, {"role": "assistant", "content": assistant}))
116
134
  current = payload.get("messages")
117
135
  prepared = dict(payload)
@@ -124,7 +142,25 @@ class SessionMemory:
124
142
  if text is None:
125
143
  value = payload.get("input")
126
144
  text = value if isinstance(value, str) else None
127
- yield MemoryTurn(prepared, text, session, settings.memory.recent_turns)
145
+ yield MemoryTurn(
146
+ prepared, text, session, settings.memory.recent_turns,
147
+ self._history if settings.history.enabled else None,
148
+ user_id, session_id, ctx.run_id,
149
+ )
150
+
151
+ async def history_page(
152
+ self, ctx: Context, settings: HarnessSettings, session_id: str, limit: int, offset: int
153
+ ) -> dict[str, Any]:
154
+ if not settings.history.enabled:
155
+ raise ValueError("Project database conversation history is disabled")
156
+ return await self._history.page(session_user_id(ctx), session_id, limit, offset)
157
+
158
+ async def history_delete(
159
+ self, ctx: Context, settings: HarnessSettings, session_id: str
160
+ ) -> dict[str, Any]:
161
+ if not settings.history.enabled:
162
+ raise ValueError("Project database conversation history is disabled")
163
+ return await self._history.delete(session_user_id(ctx), session_id)
128
164
 
129
165
  def clear(self) -> None:
130
166
  self._generation += 1
@@ -230,7 +230,7 @@ class HarnessTests(unittest.TestCase):
230
230
  {"input": "first"}, ctx, settings, root) as turn:
231
231
  started.set()
232
232
  await release.wait()
233
- turn.commit("answer-first")
233
+ await turn.commit("answer-first")
234
234
 
235
235
  async def second():
236
236
  async with memory.use({"session_id": "same", "input": "second"},
@@ -348,6 +348,107 @@ class HarnessTests(unittest.TestCase):
348
348
 
349
349
  asyncio.run(run())
350
350
 
351
+ def test_project_database_history_persists_across_runtime_restart_and_isolates_users(self):
352
+ rows = []
353
+
354
+ def query(_table, *, filters, limit, offset=0, **_kwargs):
355
+ scoped = [row for row in rows if all(row[item["field"]] == item["value"] for item in filters)]
356
+ scoped.reverse()
357
+ return {"data": scoped[offset:offset + limit], "count": len(scoped)}
358
+
359
+ def insert(_table, *, row, returning):
360
+ self.assertFalse(returning)
361
+ rows.append({**row, "created_at": len(rows)})
362
+ return {"affectedRows": 1}
363
+
364
+ def delete(_table, *, filters, returning):
365
+ self.assertFalse(returning)
366
+ before = len(rows)
367
+ rows[:] = [row for row in rows if not all(row[item["field"]] == item["value"] for item in filters)]
368
+ return {"affectedRows": before - len(rows)}
369
+
370
+ async def run():
371
+ with tempfile.TemporaryDirectory(dir=".tmp") as directory:
372
+ root = Path(directory)
373
+ (root / "config").mkdir()
374
+ (root / "config/agent_llm_config.json").write_text(json.dumps({
375
+ "harness": {"version": 1, "planning": {"mode": "off"},
376
+ "history": {"enabled": True}},
377
+ }), encoding="utf-8")
378
+ model = HarnessModel()
379
+ graph = build_agent_graph(model, system_prompt="test")
380
+ service = GraphService()
381
+
382
+ async def send(text, user="user-1", session="same"):
383
+ ctx = new_context("run")
384
+ ctx.identity.current_user_id = user
385
+ await service.run({"input": text, "session_id": session}, ctx)
386
+
387
+ with (
388
+ patch.dict(os.environ, {"LICOS_PROJECT_PATH": str(root), "LICOS_WORKSPACE_PATH": str(root)}),
389
+ patch("licos_agent_runtime.service.is_agent_project", return_value=True),
390
+ patch("licos_agent_runtime.project_history.database.query", side_effect=query),
391
+ patch("licos_agent_runtime.project_history.database.insert", side_effect=insert),
392
+ patch("licos_agent_runtime.project_history.database.delete", side_effect=delete),
393
+ patch.object(GraphService, "_get_graph", return_value=graph),
394
+ ):
395
+ for number in range(1, 5):
396
+ await send(f"turn-{number}")
397
+ self.assertEqual(len(rows), 4)
398
+ service = GraphService()
399
+ await send("turn-5")
400
+ self.assertEqual(
401
+ [message.content for message in model.seen[-1] if isinstance(message, HumanMessage)],
402
+ ["turn-2", "turn-3", "turn-4", "turn-5"],
403
+ )
404
+ await send("another-user", user="user-2")
405
+ self.assertEqual(
406
+ [message.content for message in model.seen[-1] if isinstance(message, HumanMessage)],
407
+ ["another-user"],
408
+ )
409
+ await send("another-session", session="other")
410
+ self.assertEqual(
411
+ [message.content for message in model.seen[-1] if isinstance(message, HumanMessage)],
412
+ ["another-session"],
413
+ )
414
+ ctx = new_context("history")
415
+ ctx.identity.current_user_id = "user-1"
416
+ page = await service.conversation_history("same", ctx, 2, 1)
417
+ self.assertEqual(page["count"], 5)
418
+ self.assertEqual([row["user_text"] for row in page["data"]], ["turn-4", "turn-3"])
419
+ ctx.identity.current_user_id = "user-2"
420
+ page = await service.conversation_history("same", ctx, 20, 0)
421
+ self.assertEqual([row["user_text"] for row in page["data"]], ["another-user"])
422
+ self.assertEqual((await service.delete_conversation_history("same", ctx))["affectedRows"], 1)
423
+ ctx.identity.current_user_id = "user-1"
424
+ self.assertEqual((await service.conversation_history("same", ctx, 20, 0))["count"], 5)
425
+
426
+ asyncio.run(run())
427
+
428
+ def test_enabled_project_database_history_write_failure_fails_the_run(self):
429
+ async def run():
430
+ with tempfile.TemporaryDirectory(dir=".tmp") as directory:
431
+ root = Path(directory)
432
+ (root / "config").mkdir()
433
+ (root / "config/agent_llm_config.json").write_text(json.dumps({
434
+ "harness": {"version": 1, "planning": {"mode": "off"},
435
+ "history": {"enabled": True}},
436
+ }), encoding="utf-8")
437
+ service = GraphService()
438
+ ctx = new_context("run")
439
+ ctx.identity.current_user_id = "user-1"
440
+ with (
441
+ patch.dict(os.environ, {"LICOS_PROJECT_PATH": str(root), "LICOS_WORKSPACE_PATH": str(root)}),
442
+ patch("licos_agent_runtime.service.is_agent_project", return_value=True),
443
+ patch("licos_agent_runtime.project_history.database.query", return_value={"data": []}),
444
+ patch("licos_agent_runtime.project_history.database.insert", side_effect=RuntimeError("write failed")),
445
+ patch.object(service, "_get_graph", return_value=build_agent_graph(HarnessModel(), system_prompt="test")),
446
+ ):
447
+ with self.assertRaisesRegex(RuntimeError, "write failed"):
448
+ await service.run({"input": "hello", "session_id": "same"}, ctx)
449
+
450
+ asyncio.run(run())
451
+
351
452
  def test_stream_commits_only_finished_response(self):
352
453
  async def run():
353
454
  with tempfile.TemporaryDirectory(dir=".tmp") as directory: