llm-tool-cli 0.2.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.
- llm_tool_cli/__init__.py +0 -0
- llm_tool_cli/cli/__init__.py +1 -0
- llm_tool_cli/cli/application.py +31 -0
- llm_tool_cli/cli/commands/__init__.py +0 -0
- llm_tool_cli/cli/commands/initialization.py +36 -0
- llm_tool_cli/cli/commands/skills.py +25 -0
- llm_tool_cli/cli/commands/tests/__init__.py +0 -0
- llm_tool_cli/cli/commands/tests/test_initialization.py +130 -0
- llm_tool_cli/cli/commands/tests/test_skills.py +154 -0
- llm_tool_cli/cli/commands/tests/test_version.py +98 -0
- llm_tool_cli/cli/commands/version.py +17 -0
- llm_tool_cli/cli/context.py +39 -0
- llm_tool_cli/cli/entities.py +3 -0
- llm_tool_cli/cli/errors.py +15 -0
- llm_tool_cli/cli/handling.py +41 -0
- llm_tool_cli/cli/options.py +48 -0
- llm_tool_cli/cli/tests/__init__.py +0 -0
- llm_tool_cli/cli/tests/test_application.py +167 -0
- llm_tool_cli/cli/tests/test_context.py +179 -0
- llm_tool_cli/cli/tests/test_handling.py +230 -0
- llm_tool_cli/cli/tests/test_options.py +125 -0
- llm_tool_cli/config/__init__.py +33 -0
- llm_tool_cli/config/errors.py +92 -0
- llm_tool_cli/config/fields.py +39 -0
- llm_tool_cli/config/files.py +175 -0
- llm_tool_cli/config/project.py +87 -0
- llm_tool_cli/config/runtime.py +14 -0
- llm_tool_cli/config/tests/__init__.py +0 -0
- llm_tool_cli/config/tests/fixtures.py +34 -0
- llm_tool_cli/config/tests/test_fields.py +124 -0
- llm_tool_cli/config/tests/test_files.py +826 -0
- llm_tool_cli/config/tests/test_project.py +257 -0
- llm_tool_cli/core/__init__.py +1 -0
- llm_tool_cli/core/context.py +50 -0
- llm_tool_cli/core/entities.py +35 -0
- llm_tool_cli/core/errors.py +97 -0
- llm_tool_cli/core/result.py +118 -0
- llm_tool_cli/core/settings.py +37 -0
- llm_tool_cli/core/tests/__init__.py +0 -0
- llm_tool_cli/core/tests/fixtures.py +13 -0
- llm_tool_cli/core/tests/test_context.py +152 -0
- llm_tool_cli/core/tests/test_entities.py +65 -0
- llm_tool_cli/core/tests/test_errors.py +211 -0
- llm_tool_cli/core/tests/test_result.py +256 -0
- llm_tool_cli/core/tests/test_settings.py +64 -0
- llm_tool_cli/output/__init__.py +3 -0
- llm_tool_cli/output/emitter.py +108 -0
- llm_tool_cli/output/errors.py +21 -0
- llm_tool_cli/output/journal.py +66 -0
- llm_tool_cli/output/tests/__init__.py +0 -0
- llm_tool_cli/output/tests/helpers.py +29 -0
- llm_tool_cli/output/tests/test_emitter.py +406 -0
- llm_tool_cli/output/tests/test_journal.py +141 -0
- llm_tool_cli/paths/__init__.py +51 -0
- llm_tool_cli/paths/enumeration.py +72 -0
- llm_tool_cli/paths/errors.py +42 -0
- llm_tool_cli/paths/filesystem.py +164 -0
- llm_tool_cli/paths/globs.py +124 -0
- llm_tool_cli/paths/normalization.py +58 -0
- llm_tool_cli/paths/tests/__init__.py +0 -0
- llm_tool_cli/paths/tests/test_enumeration.py +229 -0
- llm_tool_cli/paths/tests/test_filesystem.py +778 -0
- llm_tool_cli/paths/tests/test_globs.py +137 -0
- llm_tool_cli/paths/tests/test_normalization.py +163 -0
- llm_tool_cli/protocol/__init__.py +4 -0
- llm_tool_cli/protocol/cell_shortcuts.py +39 -0
- llm_tool_cli/protocol/entities.py +7 -0
- llm_tool_cli/protocol/errors.py +21 -0
- llm_tool_cli/protocol/journal.py +21 -0
- llm_tool_cli/protocol/logic_cells/__init__.py +4 -0
- llm_tool_cli/protocol/logic_cells/base.py +41 -0
- llm_tool_cli/protocol/logic_cells/content.py +26 -0
- llm_tool_cli/protocol/logic_cells/environment_error.py +36 -0
- llm_tool_cli/protocol/logic_cells/journal.py +44 -0
- llm_tool_cli/protocol/logic_cells/tests/__init__.py +0 -0
- llm_tool_cli/protocol/logic_cells/tests/test_base.py +47 -0
- llm_tool_cli/protocol/logic_cells/tests/test_content.py +59 -0
- llm_tool_cli/protocol/logic_cells/tests/test_environment_error.py +130 -0
- llm_tool_cli/protocol/logic_cells/tests/test_journal.py +47 -0
- llm_tool_cli/protocol/logic_cells/tests/test_uniform.py +60 -0
- llm_tool_cli/protocol/logic_cells/tests/test_warning.py +34 -0
- llm_tool_cli/protocol/logic_cells/uniform.py +21 -0
- llm_tool_cli/protocol/logic_cells/warning.py +22 -0
- llm_tool_cli/protocol/output_cells/__init__.py +11 -0
- llm_tool_cli/protocol/output_cells/automation.py +14 -0
- llm_tool_cli/protocol/output_cells/base.py +65 -0
- llm_tool_cli/protocol/output_cells/human.py +24 -0
- llm_tool_cli/protocol/output_cells/journal.py +23 -0
- llm_tool_cli/protocol/output_cells/llm.py +25 -0
- llm_tool_cli/protocol/output_cells/tests/__init__.py +0 -0
- llm_tool_cli/protocol/output_cells/tests/make.py +15 -0
- llm_tool_cli/protocol/output_cells/tests/test_automation.py +58 -0
- llm_tool_cli/protocol/output_cells/tests/test_base.py +86 -0
- llm_tool_cli/protocol/output_cells/tests/test_human.py +112 -0
- llm_tool_cli/protocol/output_cells/tests/test_llm.py +65 -0
- llm_tool_cli/protocol/serialization.py +7 -0
- llm_tool_cli/protocol/tests/__init__.py +0 -0
- llm_tool_cli/protocol/tests/helpers.py +25 -0
- llm_tool_cli/protocol/tests/test_cell_shortcuts.py +126 -0
- llm_tool_cli/protocol/tests/test_journal.py +44 -0
- llm_tool_cli/protocol/tests/test_serialization.py +33 -0
- llm_tool_cli/py.typed +0 -0
- llm_tool_cli/skills/__init__.py +6 -0
- llm_tool_cli/skills/errors.py +12 -0
- llm_tool_cli/skills/fixtures.py +14 -0
- llm_tool_cli/skills/tests/__init__.py +0 -0
- llm_tool_cli/skills/tests/test_fixtures.py +58 -0
- llm_tool_cli-0.2.0.dist-info/METADATA +275 -0
- llm_tool_cli-0.2.0.dist-info/RECORD +111 -0
- llm_tool_cli-0.2.0.dist-info/WHEEL +4 -0
- llm_tool_cli-0.2.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from llm_tool_cli.core.errors import EnvironmentError
|
|
7
|
+
from llm_tool_cli.protocol import Protocol
|
|
8
|
+
from llm_tool_cli.protocol.logic_cells import EnvironmentErrorCell
|
|
9
|
+
from llm_tool_cli.protocol.output_cells import AutomationOutputCell, HumanErrorOutputCell, LLMOutputCell
|
|
10
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContextFailure(EnvironmentError):
|
|
14
|
+
path: Path
|
|
15
|
+
labels: list[str]
|
|
16
|
+
retryable: bool
|
|
17
|
+
attempts: int
|
|
18
|
+
detail: str | None
|
|
19
|
+
amount: Decimal
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestEnvironmentErrorCell:
|
|
23
|
+
def test_to_journal_record__keeps_structured_error_without_private_cause(self) -> None:
|
|
24
|
+
error = ContextFailure(
|
|
25
|
+
code="unavailable",
|
|
26
|
+
message="{error.path}: failed",
|
|
27
|
+
path=Path("/project"),
|
|
28
|
+
labels=["first"],
|
|
29
|
+
retryable=False,
|
|
30
|
+
attempts=0,
|
|
31
|
+
detail=None,
|
|
32
|
+
amount=Decimal("1.5"),
|
|
33
|
+
).with_cause(OSError("private"))
|
|
34
|
+
original = error.model_dump()
|
|
35
|
+
record = EnvironmentErrorCell(error=error).to_journal_record()
|
|
36
|
+
assert record.message == "/project: failed"
|
|
37
|
+
assert record.attributes == {
|
|
38
|
+
"type": "error",
|
|
39
|
+
"code": "unavailable",
|
|
40
|
+
"path": "/project",
|
|
41
|
+
"labels": ["first"],
|
|
42
|
+
"retryable": False,
|
|
43
|
+
"attempts": 0,
|
|
44
|
+
"detail": None,
|
|
45
|
+
"amount": "1.5",
|
|
46
|
+
}
|
|
47
|
+
assert error.model_dump() == original
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize(
|
|
50
|
+
("protocol", "output_type"),
|
|
51
|
+
[
|
|
52
|
+
(Protocol.human, HumanErrorOutputCell),
|
|
53
|
+
(Protocol.llm, LLMOutputCell),
|
|
54
|
+
(Protocol.automation, AutomationOutputCell),
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
def test_render__retains_typed_error_and_projects_serialized_context(
|
|
58
|
+
self, protocol: Protocol, output_type: type[OutputCell]
|
|
59
|
+
) -> None:
|
|
60
|
+
error = ContextFailure(
|
|
61
|
+
code="unavailable",
|
|
62
|
+
message="{error.path}: café\nTry again.",
|
|
63
|
+
path=Path("/project"),
|
|
64
|
+
labels=["one", "two"],
|
|
65
|
+
retryable=False,
|
|
66
|
+
attempts=0,
|
|
67
|
+
detail=None,
|
|
68
|
+
amount=Decimal("1.5"),
|
|
69
|
+
).with_cause(OSError("private"))
|
|
70
|
+
original = error.model_dump()
|
|
71
|
+
logic_cell = EnvironmentErrorCell(error=error)
|
|
72
|
+
|
|
73
|
+
cells = logic_cell.render(protocol)
|
|
74
|
+
|
|
75
|
+
assert isinstance(logic_cell.error, ContextFailure)
|
|
76
|
+
assert logic_cell.error.path == Path("/project")
|
|
77
|
+
assert len(cells) == 1
|
|
78
|
+
cell = cells[0]
|
|
79
|
+
assert isinstance(cell, output_type)
|
|
80
|
+
assert cell.kind == "error"
|
|
81
|
+
assert cell.media_type == "text/markdown"
|
|
82
|
+
assert cell.content == "/project: café\nTry again."
|
|
83
|
+
assert cell.meta == {
|
|
84
|
+
"type": "error",
|
|
85
|
+
"code": "unavailable",
|
|
86
|
+
"path": "/project",
|
|
87
|
+
"labels": ["one", "two"],
|
|
88
|
+
"retryable": False,
|
|
89
|
+
"attempts": 0,
|
|
90
|
+
"detail": None,
|
|
91
|
+
"amount": "1.5",
|
|
92
|
+
}
|
|
93
|
+
assert error.model_dump() == original
|
|
94
|
+
assert logic_cell.error.model_dump() == original
|
|
95
|
+
assert isinstance(logic_cell.error.cause, OSError)
|
|
96
|
+
|
|
97
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
98
|
+
@pytest.mark.parametrize(
|
|
99
|
+
("guidance", "suffix"),
|
|
100
|
+
[
|
|
101
|
+
([], ""),
|
|
102
|
+
([" Retry {error.code}. "], "\nWay to fix: Retry unavailable."),
|
|
103
|
+
(
|
|
104
|
+
[" Retry {error.code}. ", "Check configuration."],
|
|
105
|
+
"\n\nWays to fix:\n\n- Retry unavailable.\n- Check configuration.",
|
|
106
|
+
),
|
|
107
|
+
],
|
|
108
|
+
)
|
|
109
|
+
def test_render__includes_formatted_guidance(self, protocol: Protocol, guidance: list[str], suffix: str) -> None:
|
|
110
|
+
error = EnvironmentError(code="unavailable", message="Service unavailable", ways_to_fix=guidance)
|
|
111
|
+
original = error.model_dump()
|
|
112
|
+
|
|
113
|
+
cell = EnvironmentErrorCell(error=error).render(protocol)[0]
|
|
114
|
+
|
|
115
|
+
assert cell.content == "Service unavailable" + suffix
|
|
116
|
+
assert cell.meta == {"type": "error", "code": "unavailable"}
|
|
117
|
+
assert error.model_dump() == original
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
120
|
+
def test_render__recomputes_output_with_fresh_identifiers(self, protocol: Protocol) -> None:
|
|
121
|
+
error = EnvironmentError(code="empty", message="")
|
|
122
|
+
cell = EnvironmentErrorCell(error=error)
|
|
123
|
+
|
|
124
|
+
first = cell.render(protocol)[0]
|
|
125
|
+
second = cell.render(protocol)[0]
|
|
126
|
+
|
|
127
|
+
assert first.id != second.id
|
|
128
|
+
assert first.model_dump(exclude={"id"}) == second.model_dump(exclude={"id"})
|
|
129
|
+
assert first.content == ""
|
|
130
|
+
assert first.meta == {"type": "error", "code": "empty"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from llm_tool_cli.core.settings import ToolLabel
|
|
6
|
+
from llm_tool_cli.protocol import Protocol
|
|
7
|
+
from llm_tool_cli.protocol.logic_cells.journal import JournalCell
|
|
8
|
+
from llm_tool_cli.protocol.output_cells.base import RenderContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestJournalCell:
|
|
12
|
+
def test_to_journal_record__preserves_timestamp_and_subclass_fields(self) -> None:
|
|
13
|
+
class EventCell(JournalCell):
|
|
14
|
+
task_id: str | None = None
|
|
15
|
+
|
|
16
|
+
cell = EventCell(message="event", actor_id="actor", task_id="T-1", is_warning=True)
|
|
17
|
+
original = cell.model_dump()
|
|
18
|
+
record = cell.to_journal_record()
|
|
19
|
+
assert record.timestamp == cell.timestamp
|
|
20
|
+
assert record.message == cell.message
|
|
21
|
+
assert record.actor_id == "actor"
|
|
22
|
+
assert record.attributes == {"task_id": "T-1"}
|
|
23
|
+
assert cell.model_dump() == original
|
|
24
|
+
|
|
25
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
26
|
+
def test_render__uses_normalized_multiline_data_and_emits_one_line(self, protocol: Protocol) -> None:
|
|
27
|
+
message = " first\nsecond\rthird\\n "
|
|
28
|
+
cell = JournalCell(message=message, actor_id=" actor\nname ")
|
|
29
|
+
context = RenderContext(index=0, total=1, tool_label=ToolLabel("TEST"))
|
|
30
|
+
rendered = cell.render(protocol)[0].render(context).decode()
|
|
31
|
+
assert cell.message == message.strip()
|
|
32
|
+
assert len(rendered.splitlines()) == 1
|
|
33
|
+
if protocol == Protocol.automation:
|
|
34
|
+
record = json.loads(rendered)
|
|
35
|
+
assert record["message"] == message.strip()
|
|
36
|
+
assert record["actor_id"] == "actor\nname"
|
|
37
|
+
assert "is_journal" not in record
|
|
38
|
+
assert "is_warning" not in record
|
|
39
|
+
else:
|
|
40
|
+
assert r"first\nsecond\rthird\\n" in rendered
|
|
41
|
+
assert r"actor\nname" in rendered
|
|
42
|
+
|
|
43
|
+
def test_render__absent_actor_and_empty_message(self) -> None:
|
|
44
|
+
cell = JournalCell(message="")
|
|
45
|
+
context = RenderContext(index=0, total=1, tool_label=ToolLabel("TEST"))
|
|
46
|
+
for protocol in [Protocol.human, Protocol.llm]:
|
|
47
|
+
assert "<->" in cell.render(protocol)[0].render(context).decode()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pytest_mock import MockerFixture
|
|
3
|
+
|
|
4
|
+
from llm_tool_cli.protocol import Protocol
|
|
5
|
+
from llm_tool_cli.protocol.errors import JournalConversionUnsupported
|
|
6
|
+
from llm_tool_cli.protocol.logic_cells.uniform import UniformCell
|
|
7
|
+
from llm_tool_cli.protocol.output_cells import AutomationOutputCell, HumanOutputCell, LLMOutputCell
|
|
8
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExampleUniformCell(UniformCell):
|
|
12
|
+
items: tuple[str, ...]
|
|
13
|
+
|
|
14
|
+
def _render(self, cell_type: type[OutputCell]) -> OutputCell:
|
|
15
|
+
return cell_type.build_markdown(kind="items", content="\n".join(self.items), items=list(self.items))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestUniformCell:
|
|
19
|
+
def test_to_journal_record__does_not_fall_back_to_rendering(self, mocker: MockerFixture) -> None:
|
|
20
|
+
cell = ExampleUniformCell(items=("first", "second"))
|
|
21
|
+
render = mocker.spy(ExampleUniformCell, "_render")
|
|
22
|
+
|
|
23
|
+
with pytest.raises(JournalConversionUnsupported):
|
|
24
|
+
cell.to_journal_record()
|
|
25
|
+
|
|
26
|
+
render.assert_not_called()
|
|
27
|
+
|
|
28
|
+
@pytest.mark.parametrize(
|
|
29
|
+
("protocol", "cell_type"),
|
|
30
|
+
[
|
|
31
|
+
(Protocol.human, HumanOutputCell),
|
|
32
|
+
(Protocol.llm, LLMOutputCell),
|
|
33
|
+
(Protocol.automation, AutomationOutputCell),
|
|
34
|
+
],
|
|
35
|
+
)
|
|
36
|
+
@pytest.mark.parametrize("items", [(), ("first", "café")])
|
|
37
|
+
def test_render__projects_one_fresh_cell_without_mutating_input(
|
|
38
|
+
self, protocol: Protocol, cell_type: type[OutputCell], items: tuple[str, ...]
|
|
39
|
+
) -> None:
|
|
40
|
+
cell = ExampleUniformCell(items=items)
|
|
41
|
+
original = cell.model_dump()
|
|
42
|
+
|
|
43
|
+
first = cell.render(protocol)
|
|
44
|
+
second = cell.render(protocol)
|
|
45
|
+
|
|
46
|
+
assert len(first) == len(second) == 1
|
|
47
|
+
assert isinstance(first[0], cell_type)
|
|
48
|
+
assert isinstance(second[0], cell_type)
|
|
49
|
+
assert (
|
|
50
|
+
first[0].model_dump(exclude={"id"})
|
|
51
|
+
== second[0].model_dump(exclude={"id"})
|
|
52
|
+
== {
|
|
53
|
+
"kind": "items",
|
|
54
|
+
"media_type": "text/markdown",
|
|
55
|
+
"content": "\n".join(items),
|
|
56
|
+
"meta": {"items": list(items)},
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
assert first[0].id != second[0].id
|
|
60
|
+
assert cell.model_dump() == original
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from llm_tool_cli.protocol import Protocol
|
|
4
|
+
from llm_tool_cli.protocol.logic_cells.warning import WarningCell
|
|
5
|
+
from llm_tool_cli.protocol.output_cells import AutomationOutputCell, HumanWarningOutputCell, LLMOutputCell
|
|
6
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestWarningCell:
|
|
10
|
+
def test_to_journal_record__retains_warning_message(self) -> None:
|
|
11
|
+
cell = WarningCell(message="first\nsecond", is_journal=True)
|
|
12
|
+
record = cell.to_journal_record()
|
|
13
|
+
assert record.message == "first\nsecond"
|
|
14
|
+
assert record.attributes == {"type": "warning"}
|
|
15
|
+
|
|
16
|
+
@pytest.mark.parametrize(
|
|
17
|
+
("protocol", "output_type"),
|
|
18
|
+
[
|
|
19
|
+
(Protocol.human, HumanWarningOutputCell),
|
|
20
|
+
(Protocol.llm, LLMOutputCell),
|
|
21
|
+
(Protocol.automation, AutomationOutputCell),
|
|
22
|
+
],
|
|
23
|
+
)
|
|
24
|
+
def test_render__preserves_warning_payload_without_delivery_flags(
|
|
25
|
+
self, protocol: Protocol, output_type: type[OutputCell]
|
|
26
|
+
) -> None:
|
|
27
|
+
cell = WarningCell(message="first\nsecond", is_warning=False)
|
|
28
|
+
rendered = cell.render(protocol)
|
|
29
|
+
assert len(rendered) == 1
|
|
30
|
+
assert isinstance(rendered[0], output_type)
|
|
31
|
+
assert rendered[0].kind == "warning"
|
|
32
|
+
assert rendered[0].content is None
|
|
33
|
+
assert rendered[0].meta == {"type": "warning", "message": "first\nsecond"}
|
|
34
|
+
assert "is_warning" not in cell.model_dump()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
|
|
3
|
+
from llm_tool_cli.protocol.logic_cells.base import LogicCell
|
|
4
|
+
from llm_tool_cli.protocol.output_cells import AutomationOutputCell, HumanOutputCell, LLMOutputCell
|
|
5
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UniformCell(LogicCell):
|
|
9
|
+
"""One output cell with the same payload in each output protocol."""
|
|
10
|
+
|
|
11
|
+
def render_human(self) -> list[OutputCell]:
|
|
12
|
+
return [self._render(HumanOutputCell)]
|
|
13
|
+
|
|
14
|
+
def render_llm(self) -> list[OutputCell]:
|
|
15
|
+
return [self._render(LLMOutputCell)]
|
|
16
|
+
|
|
17
|
+
def render_automation(self) -> list[OutputCell]:
|
|
18
|
+
return [self._render(AutomationOutputCell)]
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def _render(self, cell_type: type[OutputCell]) -> OutputCell: ... # noqa: E704
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import pydantic
|
|
2
|
+
|
|
3
|
+
from llm_tool_cli.protocol.journal import JournalRecord
|
|
4
|
+
from llm_tool_cli.protocol.logic_cells.uniform import UniformCell
|
|
5
|
+
from llm_tool_cli.protocol.output_cells import HumanWarningOutputCell
|
|
6
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WarningCell(UniformCell):
|
|
10
|
+
"""A non-fatal diagnostic delivered after ordinary results."""
|
|
11
|
+
|
|
12
|
+
is_warning: bool = pydantic.Field(default=True, exclude=True)
|
|
13
|
+
message: str
|
|
14
|
+
|
|
15
|
+
def render_human(self) -> list[OutputCell]:
|
|
16
|
+
return [self._render(HumanWarningOutputCell)]
|
|
17
|
+
|
|
18
|
+
def to_journal_record(self) -> JournalRecord:
|
|
19
|
+
return JournalRecord(message=self.message, attributes={"type": "warning"})
|
|
20
|
+
|
|
21
|
+
def _render(self, cell_type: type[OutputCell]) -> OutputCell:
|
|
22
|
+
return cell_type.build_meta(kind="warning", type="warning", message=self.message)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from llm_tool_cli.protocol.output_cells.automation import AutomationOutputCell
|
|
2
|
+
from llm_tool_cli.protocol.output_cells.human import HumanErrorOutputCell, HumanOutputCell, HumanWarningOutputCell
|
|
3
|
+
from llm_tool_cli.protocol.output_cells.llm import LLMOutputCell
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"AutomationOutputCell",
|
|
7
|
+
"HumanErrorOutputCell",
|
|
8
|
+
"HumanOutputCell",
|
|
9
|
+
"HumanWarningOutputCell",
|
|
10
|
+
"LLMOutputCell",
|
|
11
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from llm_tool_cli.protocol import to_jsonl
|
|
2
|
+
from llm_tool_cli.protocol.output_cells.base import MetaValue, OutputCell, RenderContext
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AutomationOutputCell(OutputCell):
|
|
6
|
+
def render(self, context: RenderContext) -> bytes:
|
|
7
|
+
data: dict[str, MetaValue] = {"id": self.short_id}
|
|
8
|
+
|
|
9
|
+
for meta_key, meta_value in sorted(self.meta.items()):
|
|
10
|
+
data[meta_key] = meta_value
|
|
11
|
+
|
|
12
|
+
data["content"] = self.content.strip() if self.content else None
|
|
13
|
+
|
|
14
|
+
return to_jsonl(data).encode("utf-8")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import uuid
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
import pydantic
|
|
7
|
+
|
|
8
|
+
from llm_tool_cli.core.entities import BaseEntity
|
|
9
|
+
|
|
10
|
+
MetaValue = str | int | bool | None | list[str]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_meta_value(value: object) -> MetaValue:
|
|
14
|
+
if isinstance(value, (str, int, bool)) or value is None:
|
|
15
|
+
return value
|
|
16
|
+
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
return str(value)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RenderContext(BaseEntity):
|
|
23
|
+
model_config = pydantic.ConfigDict(str_strip_whitespace=False)
|
|
24
|
+
|
|
25
|
+
index: int = pydantic.Field(ge=0)
|
|
26
|
+
total: int = pydantic.Field(ge=1)
|
|
27
|
+
tool_label: str
|
|
28
|
+
|
|
29
|
+
@pydantic.model_validator(mode="after")
|
|
30
|
+
def validate_position(self) -> Self:
|
|
31
|
+
if self.index >= self.total:
|
|
32
|
+
raise ValueError("Cell index must be smaller than the sequence size")
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OutputCell(BaseEntity, ABC):
|
|
37
|
+
id: uuid.UUID = pydantic.Field(default_factory=uuid.uuid4)
|
|
38
|
+
kind: str
|
|
39
|
+
media_type: str | None
|
|
40
|
+
content: str | None
|
|
41
|
+
meta: dict[str, MetaValue] = pydantic.Field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def build(cls, kind: str, media_type: str | None, content: str | None, **meta: MetaValue) -> Self:
|
|
45
|
+
if media_type is None and content is not None:
|
|
46
|
+
from llm_tool_cli.protocol.errors import ContentWithoutMediaType
|
|
47
|
+
|
|
48
|
+
raise ContentWithoutMediaType()
|
|
49
|
+
|
|
50
|
+
return cls(kind=kind, media_type=media_type, content=content, meta=meta)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def build_meta(cls, kind: str, **meta: MetaValue) -> Self:
|
|
54
|
+
return cls.build(kind=kind, media_type=None, content=None, **meta)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def build_markdown(cls, kind: str, content: str, **meta: MetaValue) -> Self:
|
|
58
|
+
return cls.build(kind=kind, media_type="text/markdown", content=content, **meta)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def short_id(self) -> str:
|
|
62
|
+
return base64.urlsafe_b64encode(self.id.bytes).rstrip(b"=").decode()
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def render(self, context: RenderContext) -> bytes: ... # noqa: E704
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell, RenderContext
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HumanOutputCell(OutputCell):
|
|
5
|
+
def render(self, context: RenderContext) -> bytes:
|
|
6
|
+
sections = [self._render_metadata(), (self.content or "").strip()]
|
|
7
|
+
text = "\n\n".join(section for section in sections if section) or self.kind
|
|
8
|
+
return (text + "\n\n").encode("utf-8")
|
|
9
|
+
|
|
10
|
+
def _render_metadata(self) -> str:
|
|
11
|
+
return "\n".join(
|
|
12
|
+
f"{key}: {value}" for key, value in sorted(self.meta.items()) if not (key == "type" and value == self.kind)
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HumanErrorOutputCell(HumanOutputCell):
|
|
17
|
+
def render(self, context: RenderContext) -> bytes:
|
|
18
|
+
sections = [f"Error: {(self.content or '').strip()}".rstrip(), self._render_metadata()]
|
|
19
|
+
return ("\n\n".join(section for section in sections if section) + "\n\n").encode("utf-8")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HumanWarningOutputCell(HumanOutputCell):
|
|
23
|
+
def render(self, context: RenderContext) -> bytes:
|
|
24
|
+
return (f"Warning: {self.meta['message']}".rstrip() + "\n\n").encode("utf-8")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import pydantic
|
|
2
|
+
|
|
3
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell, RenderContext
|
|
4
|
+
from llm_tool_cli.protocol.serialization import to_jsonl
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class JournalTextOutputCell(OutputCell):
|
|
8
|
+
"""Compact journal presentation without changing the logical message."""
|
|
9
|
+
|
|
10
|
+
model_config = pydantic.ConfigDict(str_strip_whitespace=False)
|
|
11
|
+
|
|
12
|
+
def render(self, context: RenderContext) -> bytes:
|
|
13
|
+
text = (self.content or "").replace("\\", "\\\\").replace("\r", "\\r").replace("\n", "\\n")
|
|
14
|
+
return (text + "\n").encode("utf-8")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JournalAutomationOutputCell(OutputCell):
|
|
18
|
+
"""One structured journal record with its original field values."""
|
|
19
|
+
|
|
20
|
+
model_config = pydantic.ConfigDict(str_strip_whitespace=False)
|
|
21
|
+
|
|
22
|
+
def render(self, context: RenderContext) -> bytes:
|
|
23
|
+
return to_jsonl(self.meta).encode("utf-8")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell, RenderContext
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LLMOutputCell(OutputCell):
|
|
5
|
+
def render(self, context: RenderContext) -> bytes: # noqa: CCR001
|
|
6
|
+
id = self.short_id
|
|
7
|
+
|
|
8
|
+
lines = [f"--{context.tool_label}-CELL {id} BEGIN--"]
|
|
9
|
+
|
|
10
|
+
lines.append(f"kind={self.kind}")
|
|
11
|
+
|
|
12
|
+
if self.media_type is not None:
|
|
13
|
+
lines.append(f"media_type={self.media_type}")
|
|
14
|
+
|
|
15
|
+
for meta_key, meta_value in sorted(self.meta.items()):
|
|
16
|
+
lines.append(f"{meta_key}={meta_value}")
|
|
17
|
+
|
|
18
|
+
if self.content:
|
|
19
|
+
lines.append("")
|
|
20
|
+
lines.append(self.content.strip())
|
|
21
|
+
|
|
22
|
+
lines.append(f"--{context.tool_label}-CELL {id} END--")
|
|
23
|
+
lines.append("")
|
|
24
|
+
|
|
25
|
+
return "\n".join(lines).encode()
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from llm_tool_cli.protocol.output_cells.base import OutputCell
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def cell(cell_type: type[OutputCell], **kwargs: object) -> OutputCell:
|
|
7
|
+
values = {
|
|
8
|
+
"id": uuid.UUID("12345678-1234-5678-9234-567812345678"),
|
|
9
|
+
"kind": "sample_status",
|
|
10
|
+
"media_type": "text/markdown",
|
|
11
|
+
"content": " Sample content. ",
|
|
12
|
+
"meta": {"zeta": 2, "alpha": "first", "enabled": True, "missing": None},
|
|
13
|
+
}
|
|
14
|
+
values.update(kwargs)
|
|
15
|
+
return cell_type.model_validate(values)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from llm_tool_cli.protocol.output_cells.automation import AutomationOutputCell
|
|
6
|
+
from llm_tool_cli.protocol.output_cells.base import RenderContext
|
|
7
|
+
from llm_tool_cli.protocol.output_cells.tests.make import cell
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestAutomationOutputCell:
|
|
11
|
+
def test_render__serializes_cell_as_sorted_json_line(self) -> None:
|
|
12
|
+
formatted = cell(
|
|
13
|
+
AutomationOutputCell,
|
|
14
|
+
).render(RenderContext(index=0, total=1, tool_label="TOOL"))
|
|
15
|
+
|
|
16
|
+
assert json.loads(formatted) == {
|
|
17
|
+
"alpha": "first",
|
|
18
|
+
"content": "Sample content.",
|
|
19
|
+
"enabled": True,
|
|
20
|
+
"id": "EjRWeBI0VniSNFZ4EjRWeA",
|
|
21
|
+
"missing": None,
|
|
22
|
+
"zeta": 2,
|
|
23
|
+
}
|
|
24
|
+
assert formatted == (
|
|
25
|
+
b'{"alpha":"first","content":"Sample content.","enabled":true,'
|
|
26
|
+
b'"id":"EjRWeBI0VniSNFZ4EjRWeA","missing":null,"zeta":2}\n'
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@pytest.mark.parametrize("content", [None, ""])
|
|
30
|
+
def test_render__serializes_missing_content_as_null(self, content: str | None) -> None:
|
|
31
|
+
formatted = cell(AutomationOutputCell, content=content).render(
|
|
32
|
+
RenderContext(index=0, total=1, tool_label="TOOL")
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assert json.loads(formatted)["content"] is None
|
|
36
|
+
|
|
37
|
+
def test_render__preserves_metadata_field_precedence(self) -> None:
|
|
38
|
+
source = cell(AutomationOutputCell, content="Actual content", meta={"id": "metadata-id", "content": "ignored"})
|
|
39
|
+
|
|
40
|
+
formatted = source.render(RenderContext(index=0, total=1, tool_label="TOOL"))
|
|
41
|
+
|
|
42
|
+
assert json.loads(formatted) == {"id": "metadata-id", "content": "Actual content"}
|
|
43
|
+
|
|
44
|
+
def test_render__preserves_unicode_and_list_metadata(self) -> None:
|
|
45
|
+
source = cell(
|
|
46
|
+
AutomationOutputCell, content="日本語\nSecond line.", meta={"labels": ["一", "two"], "enabled": False}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
formatted = source.render(RenderContext(index=0, total=1, tool_label="TOOL"))
|
|
50
|
+
|
|
51
|
+
assert json.loads(formatted) == {
|
|
52
|
+
"id": source.short_id,
|
|
53
|
+
"content": "日本語\nSecond line.",
|
|
54
|
+
"labels": ["一", "two"],
|
|
55
|
+
"enabled": False,
|
|
56
|
+
}
|
|
57
|
+
assert "日本語".encode() in formatted
|
|
58
|
+
assert len(formatted.splitlines()) == 1
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from llm_tool_cli.protocol.errors import ContentWithoutMediaType
|
|
7
|
+
from llm_tool_cli.protocol.output_cells import HumanOutputCell
|
|
8
|
+
from llm_tool_cli.protocol.output_cells.base import MetaValue, RenderContext, to_meta_value
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestToMetaValue:
|
|
12
|
+
@pytest.mark.parametrize(
|
|
13
|
+
("value", "expected"),
|
|
14
|
+
(
|
|
15
|
+
("value", "value"),
|
|
16
|
+
(" Заметка ", " Заметка "),
|
|
17
|
+
(1, 1),
|
|
18
|
+
(True, True),
|
|
19
|
+
(None, None),
|
|
20
|
+
([], []),
|
|
21
|
+
(["first", " second "], ["first", " second "]),
|
|
22
|
+
(["first", 2], "['first', 2]"),
|
|
23
|
+
({"count": 2}, "{'count': 2}"),
|
|
24
|
+
(1.5, "1.5"),
|
|
25
|
+
(uuid.UUID("12345678-1234-5678-9234-567812345678"), "12345678-1234-5678-9234-567812345678"),
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
def test_converts_metadata_values(self, value: object, expected: MetaValue) -> None:
|
|
29
|
+
assert to_meta_value(value) == expected
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestOutputCell:
|
|
33
|
+
@pytest.mark.parametrize("content", ("content", ""))
|
|
34
|
+
def test_build__requires_media_type_when_content_is_present(self, content: str) -> None:
|
|
35
|
+
with pytest.raises(ContentWithoutMediaType):
|
|
36
|
+
HumanOutputCell.build(kind="status", media_type=None, content=content)
|
|
37
|
+
|
|
38
|
+
def test_build__stores_metadata_as_cell_metadata(self) -> None:
|
|
39
|
+
cell = HumanOutputCell.build(kind="status", media_type="text/plain", content="content", count=2, label="ready")
|
|
40
|
+
|
|
41
|
+
assert cell.meta == {"count": 2, "label": "ready"}
|
|
42
|
+
|
|
43
|
+
def test_build__accepts_media_type_without_content(self) -> None:
|
|
44
|
+
cell = HumanOutputCell.build(kind="status", media_type="text/plain", content=None)
|
|
45
|
+
|
|
46
|
+
assert cell.media_type == "text/plain"
|
|
47
|
+
assert cell.content is None
|
|
48
|
+
|
|
49
|
+
def test_build_meta__generates_fresh_uuid4_identifiers(self) -> None:
|
|
50
|
+
first = HumanOutputCell.build_meta(kind="status")
|
|
51
|
+
second = HumanOutputCell.build_meta(kind="status")
|
|
52
|
+
|
|
53
|
+
assert first.id.version == 4
|
|
54
|
+
assert second.id.version == 4
|
|
55
|
+
assert first.id != second.id
|
|
56
|
+
|
|
57
|
+
def test_build_meta__creates_contentless_cell(self) -> None:
|
|
58
|
+
cell = HumanOutputCell.build_meta(kind="status", count=0)
|
|
59
|
+
|
|
60
|
+
assert cell.media_type is None
|
|
61
|
+
assert cell.content is None
|
|
62
|
+
assert cell.meta == {"count": 0}
|
|
63
|
+
|
|
64
|
+
def test_build_markdown__uses_markdown_media_type(self) -> None:
|
|
65
|
+
cell = HumanOutputCell.build_markdown(kind="status", content="# Done", count=1)
|
|
66
|
+
|
|
67
|
+
assert cell.media_type == "text/markdown"
|
|
68
|
+
assert cell.meta == {"count": 1}
|
|
69
|
+
|
|
70
|
+
def test_short_id__returns_unpadded_urlsafe_base64_id(self) -> None:
|
|
71
|
+
cell = HumanOutputCell.build_meta(kind="status").replace(id=uuid.UUID("12345678-1234-5678-9234-567812345678"))
|
|
72
|
+
|
|
73
|
+
assert cell.short_id == "EjRWeBI0VniSNFZ4EjRWeA"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestRenderContext:
|
|
77
|
+
@pytest.mark.parametrize(("index", "total"), [(-1, 1), (0, 0), (1, 1), (3, 2)])
|
|
78
|
+
def test_validate_position__rejects_invalid_sequence_position(self, index: int, total: int) -> None:
|
|
79
|
+
with pytest.raises(pydantic.ValidationError):
|
|
80
|
+
RenderContext(index=index, total=total, tool_label="TOOL")
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize(("index", "total"), [(0, 1), (0, 2), (1, 2)])
|
|
83
|
+
def test_validate_position__accepts_sequence_boundaries(self, index: int, total: int) -> None:
|
|
84
|
+
context = RenderContext(index=index, total=total, tool_label="TOOL")
|
|
85
|
+
|
|
86
|
+
assert (context.index, context.total) == (index, total)
|