dostuff 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.
Files changed (57) hide show
  1. dostuff/__init__.py +2 -0
  2. dostuff/agent/call_agent.py +44 -0
  3. dostuff/agent/loop.py +161 -0
  4. dostuff/agent/run_tool.py +52 -0
  5. dostuff/cli.py +105 -0
  6. dostuff/cli_tui.py +647 -0
  7. dostuff/config.py +93 -0
  8. dostuff/helpers/agent/append_step.py +11 -0
  9. dostuff/helpers/agent/constants.py +187 -0
  10. dostuff/helpers/agent/extract_text.py +13 -0
  11. dostuff/helpers/agent/get_model_token_limit.py +11 -0
  12. dostuff/helpers/agent/learn_from_session.py +52 -0
  13. dostuff/helpers/agent/load_identity.py +17 -0
  14. dostuff/helpers/agent/load_project_instructions.py +36 -0
  15. dostuff/helpers/agent/manage_context.py +94 -0
  16. dostuff/helpers/agent/save_memories_and_exit.py +49 -0
  17. dostuff/helpers/mcp/load_mcp_config.py +32 -0
  18. dostuff/helpers/mcp/mcp_oauth.py +229 -0
  19. dostuff/helpers/memory/extract_episodic_memory.py +97 -0
  20. dostuff/helpers/memory/extract_semantic_memories.py +66 -0
  21. dostuff/helpers/memory/format_transcript.py +92 -0
  22. dostuff/helpers/memory/resolve_memory_operation.py +49 -0
  23. dostuff/helpers/skills/discover_skills.py +35 -0
  24. dostuff/helpers/tools/generate_tool_schema.py +62 -0
  25. dostuff/helpers/tools/resolve_safe_path.py +15 -0
  26. dostuff/helpers/ui/__init__.py +0 -0
  27. dostuff/helpers/ui/emit.py +24 -0
  28. dostuff/lib/exceptions.py +11 -0
  29. dostuff/lib/mcp/mcp_client.py +186 -0
  30. dostuff/lib/mcp/mcp_client_registration_store.py +48 -0
  31. dostuff/lib/mcp/mcp_tool_registry_store.py +78 -0
  32. dostuff/lib/memory/episodic_memory_store.py +112 -0
  33. dostuff/lib/memory/semantic_memory_store.py +51 -0
  34. dostuff/lib/memory/session_store.py +198 -0
  35. dostuff/lib/memory/types.py +36 -0
  36. dostuff/lib/model.py +22 -0
  37. dostuff/lib/tracing.py +78 -0
  38. dostuff/memory/__init__.py +6 -0
  39. dostuff/skills.py +3 -0
  40. dostuff/tools/__init__.py +1 -0
  41. dostuff/tools/bash/bash_command.py +63 -0
  42. dostuff/tools/definitions.py +35 -0
  43. dostuff/tools/delegate_to_subagent.py +74 -0
  44. dostuff/tools/files/delete_file.py +33 -0
  45. dostuff/tools/files/list_files.py +33 -0
  46. dostuff/tools/files/read_file.py +21 -0
  47. dostuff/tools/files/write_file.py +31 -0
  48. dostuff/tools/get_current_datetime.py +17 -0
  49. dostuff/tools/mcp/call_mcp_tool.py +83 -0
  50. dostuff/tools/mcp/get_mcp_tool_details.py +19 -0
  51. dostuff/tools/mcp/search_mcp_tools.py +55 -0
  52. dostuff-0.1.0.dist-info/METADATA +458 -0
  53. dostuff-0.1.0.dist-info/RECORD +57 -0
  54. dostuff-0.1.0.dist-info/WHEEL +5 -0
  55. dostuff-0.1.0.dist-info/entry_points.txt +2 -0
  56. dostuff-0.1.0.dist-info/licenses/LICENSE +21 -0
  57. dostuff-0.1.0.dist-info/top_level.txt +1 -0
dostuff/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # Package init — lazy, doesn't pull agent loop on import
2
+ __version__ = "1.0.0"
@@ -0,0 +1,44 @@
1
+ from dostuff.lib.tracing import traced
2
+ from opentelemetry import trace as otel_trace
3
+ from dostuff.tools.definitions import TOOL_SCHEMAS
4
+ from litellm import acompletion
5
+ from dostuff.lib.model import MODEL
6
+
7
+ @traced("model_call")
8
+ async def call_agent(steps_history: list, system_instruction: str, tool_names: list[str] | None = None):
9
+ if tool_names is None:
10
+ tool_names = list(TOOL_SCHEMAS.keys())
11
+
12
+ active_schemas = [
13
+ TOOL_SCHEMAS[name]
14
+ for name in tool_names
15
+ if name in TOOL_SCHEMAS
16
+ ]
17
+
18
+ messages = [{"role": "system", "content": system_instruction}] + steps_history
19
+
20
+ async def _make_request():
21
+ return await acompletion(
22
+ model=MODEL,
23
+ messages=messages,
24
+ tools=active_schemas,
25
+ drop_invalid_params=True
26
+ )
27
+
28
+ try:
29
+ interaction = await _make_request()
30
+ except Exception as e:
31
+ if "malformed_tool_call" not in str(e):
32
+ raise
33
+ from dostuff.helpers.ui.emit import emit
34
+ emit("Model returned malformed JSON — retrying once...", msg_type="system")
35
+ interaction = await _make_request()
36
+
37
+ usage = getattr(interaction, "usage", None)
38
+ if usage is not None:
39
+ span = otel_trace.get_current_span()
40
+ span.set_attribute("usage.total_tokens", getattr(usage, "total_tokens", 0))
41
+ span.set_attribute("usage.input_tokens", getattr(usage, "prompt_tokens", 0))
42
+ span.set_attribute("usage.output_tokens", getattr(usage, "completion_tokens", 0))
43
+
44
+ return interaction
dostuff/agent/loop.py ADDED
@@ -0,0 +1,161 @@
1
+ from dostuff.helpers.agent.get_model_token_limit import get_model_token_limit
2
+ from dostuff.lib.exceptions import ConfirmationRequired
3
+ from dostuff.lib.mcp.mcp_client import MCPClient
4
+ from dostuff.lib.memory.session_store import SessionStore
5
+ from dostuff.lib.tracing import tracer
6
+ from dostuff.helpers.agent.constants import MAX_ITERATIONS
7
+ from dostuff.helpers.agent.manage_context import compact_context
8
+ from typing import Literal, Optional, Any
9
+ from dostuff.agent.call_agent import call_agent
10
+ from opentelemetry.trace import Status, StatusCode
11
+ from dostuff.helpers.agent.append_step import append_step
12
+ import asyncio
13
+ from dostuff.agent.run_tool import run_tool
14
+ import json
15
+
16
+ async def loop(session_id: str, turn_id: str, user_text: str, dynamic_system_instruction: str, mcp_client: MCPClient, working_history: list, turn_type: Literal['interactive_loop', 'learning_loop', 'subagent_loop'], current_session_history: list = [], steps_history: list = [], store: SessionStore | None = None, adapter: Optional[Any] = None) -> str:
17
+
18
+ async def _emit(et, data):
19
+ if adapter and hasattr(adapter, "emit"):
20
+ try:
21
+ adapter.emit(et, data)
22
+ except Exception:
23
+ pass
24
+
25
+ iteration = 0
26
+ last_input_tokens = 0
27
+ token_limit = get_model_token_limit()
28
+ context_token_threshold = int(token_limit * 0.5)
29
+ keep_recent_token_budget = int(context_token_threshold * 0.15)
30
+ compaction_notes = ""
31
+
32
+ with tracer.start_as_current_span("turn") as turn_span:
33
+ turn_span.set_attribute("session_id", session_id)
34
+ turn_span.set_attribute("turn_type", turn_type)
35
+ turn_span.set_attribute("turn_id", turn_id)
36
+ turn_span.set_attribute("user_input", user_text)
37
+ while iteration < MAX_ITERATIONS:
38
+ iteration += 1
39
+ with tracer.start_as_current_span("iteration") as iter_span:
40
+ iter_span.set_attribute("iteration_number", iteration)
41
+
42
+ # context compaction
43
+ if last_input_tokens > context_token_threshold:
44
+ working_history, new_summary = await compact_context(working_history, keep_recent_token_budget)
45
+ compaction_notes = f"{compaction_notes}\n{new_summary}".strip()
46
+ with tracer.start_as_current_span("context_compaction") as compaction_span:
47
+ compaction_span.set_attribute("steps_after", len(working_history))
48
+
49
+ # agent call
50
+ interaction = await call_agent(steps_history=working_history, system_instruction=dynamic_system_instruction + (f"\n\n[Summary of earlier conversation]: {compaction_notes}" if compaction_notes else ""))
51
+
52
+ # token tracking
53
+ usage = getattr(interaction, "usage", None)
54
+ if usage:
55
+ last_input_tokens = getattr(usage, "total_tokens", last_input_tokens)
56
+
57
+ # CustomStreamWrapper does not expose ``choices`` in its static type,
58
+ # although the completed interaction provides it at runtime.
59
+ choices = getattr(interaction, "choices", None)
60
+ if not choices:
61
+ iter_span.set_status(Status(StatusCode.OK))
62
+ continue
63
+ message = choices[0].message
64
+ tool_calls = getattr(message, "tool_calls", None)
65
+ content = getattr(message, "content", None)
66
+
67
+ if content and not tool_calls:
68
+ model_step = {
69
+ "role": "assistant",
70
+ "content": content,
71
+ }
72
+ await append_step(model_step, steps_history, working_history, current_session_history, session_id, store, turn_type)
73
+
74
+ turn_span.set_attribute("outcome", "success")
75
+ turn_span.set_status(Status(StatusCode.OK))
76
+ iter_span.set_status(Status(StatusCode.OK))
77
+ # print(f"\n\nAgent: {content}")
78
+ if usage:
79
+ usage_dict = {
80
+ "prompt_tokens": getattr(usage, "prompt_tokens", 0) or 0,
81
+ "completion_tokens": getattr(usage, "completion_tokens", 0) or 0,
82
+ "total_tokens": getattr(usage, "total_tokens", 0) or 0,
83
+ }
84
+ await _emit("usage", usage_dict)
85
+ return content
86
+
87
+ if not tool_calls:
88
+ iter_span.set_status(Status(StatusCode.OK))
89
+ continue
90
+
91
+ function_calls = []
92
+ for tool_call in tool_calls:
93
+ if tool_call.type == "function":
94
+ fn_name = tool_call.function.name
95
+ fn_args = json.loads(tool_call.function.arguments) if isinstance(tool_call.function.arguments, str) else tool_call.function.arguments
96
+ fn_id = tool_call.id
97
+ function_calls.append((fn_name, fn_args, fn_id))
98
+
99
+ assistant_tool_step = {
100
+ "role": "assistant",
101
+ "content": content,
102
+ "tool_calls": [t.model_dump() for t in tool_calls]
103
+ }
104
+ await append_step(assistant_tool_step, steps_history, working_history, current_session_history, session_id, store, turn_type)
105
+
106
+
107
+ for fn_name, fn_args, _ in function_calls:
108
+ await _emit("tool_call", f"{fn_name}({fn_args})")
109
+
110
+ results = await asyncio.gather(
111
+ *(asyncio.wait_for(
112
+ run_tool(fn_name=fn_name, fn_args=dict(fn_args), mcp_client=mcp_client, session_id=session_id, turn_id=turn_id),
113
+ timeout=120.0 # 2 min per tool call; prevents hung MCP servers from blocking the turn
114
+ ) for fn_name, fn_args, _ in function_calls),
115
+ return_exceptions=True,
116
+ )
117
+ final_results = []
118
+ for (fn_name, fn_args, fn_id), result in zip(function_calls, results):
119
+ if isinstance(result, ConfirmationRequired):
120
+ if adapter and hasattr(adapter, "ask"):
121
+ confirm = await adapter.ask(result.message, result.resume_args)
122
+ else:
123
+ if adapter and hasattr(adapter, "emit"):
124
+ adapter.emit("confirm", result.message)
125
+ else:
126
+ print(f"\nConfirmation needed: {result.message}")
127
+ confirm = await asyncio.to_thread(input, "Allow this? [y/n]: ")
128
+ confirm = confirm.strip().lower() == "y"
129
+
130
+ if confirm:
131
+ resumed_args = {**fn_args, **result.resume_args}
132
+ result = await run_tool(fn_name=fn_name, fn_args=resumed_args, mcp_client=mcp_client, session_id=session_id, turn_id=turn_id)
133
+ else:
134
+ result = "Error: User declined to allow this action."
135
+
136
+ elif isinstance(result, Exception):
137
+ result = f"Error: {result}"
138
+
139
+ final_results.append((fn_name, fn_id, result))
140
+ await _emit("tool_result", f"{fn_name}: {str(result)[:200]}")
141
+
142
+ for fn_name, fn_id, result in final_results:
143
+ result_step = {
144
+ "role": "tool",
145
+ "name": fn_name,
146
+ "tool_call_id": fn_id,
147
+ "content": str(result)
148
+ }
149
+ await append_step(result_step, steps_history, working_history, current_session_history, session_id, store, turn_type)
150
+ iter_span.set_status(Status(StatusCode.OK))
151
+
152
+ else:
153
+ msg = f"Reached maximum iterations ({MAX_ITERATIONS}) without receiving a model output. Ending the agent loop."
154
+ if adapter and hasattr(adapter, "emit"):
155
+ adapter.emit("system", msg)
156
+ else:
157
+ print(msg)
158
+ turn_span.set_attribute("outcome", "max_iterations_exceeded")
159
+ turn_span.set_status(Status(StatusCode.ERROR, "max_iterations_exceeded"))
160
+ return msg
161
+
@@ -0,0 +1,52 @@
1
+ from dostuff.tools.definitions import TOOL_MAP
2
+ from typing import Any
3
+ from dostuff.lib.tracing import traced
4
+ import inspect
5
+ import asyncio
6
+ from dostuff.tools.mcp.call_mcp_tool import _impl_call_mcp_tool
7
+ from dostuff.tools.mcp.get_mcp_tool_details import _impl_get_mcp_tool_details
8
+ from dostuff.tools.mcp.search_mcp_tools import _impl_search_mcp_tools
9
+ from dostuff.lib.mcp.mcp_client import MCPClient
10
+ from dostuff.tools.delegate_to_subagent import _impl_delegate_to_subagent
11
+
12
+ MCP_META_TOOLS = {
13
+ "search_mcp_tools": _impl_search_mcp_tools,
14
+ "get_mcp_tool_details": _impl_get_mcp_tool_details,
15
+ "call_mcp_tool": _impl_call_mcp_tool,
16
+ }
17
+
18
+ SUBAGENT_META_TOOLS = {
19
+ "delegate_to_subagent": _impl_delegate_to_subagent
20
+ }
21
+
22
+ @traced("tool_call")
23
+ async def run_tool(fn_name: str | None, fn_args: dict[str, Any], mcp_client: MCPClient | None = None, session_id: str | None = None, turn_id: str | None = None) -> Any:
24
+ if not isinstance(fn_name, str):
25
+ raise ValueError("Tool name must be a string.")
26
+
27
+ if fn_name in SUBAGENT_META_TOOLS:
28
+ if not session_id or not turn_id or not mcp_client:
29
+ raise ValueError(f"Cannot spawn subagent without all required arguments.")
30
+ fn = SUBAGENT_META_TOOLS[fn_name]
31
+ if inspect.iscoroutinefunction(fn):
32
+ return await fn(**fn_args, mcp_client=mcp_client, session_id = session_id, turn_id=turn_id)
33
+ else:
34
+ return await asyncio.to_thread(fn, **fn_args, mcp_client=mcp_client, session_id = session_id, turn_id=turn_id)
35
+
36
+ if fn_name in MCP_META_TOOLS:
37
+ if not mcp_client:
38
+ raise ValueError(f"Cannot execute MCP tool '{fn_name}' without an active mcp_client.")
39
+ fn = MCP_META_TOOLS[fn_name]
40
+ if inspect.iscoroutinefunction(fn):
41
+ return await fn(**fn_args, mcp_client=mcp_client)
42
+ else:
43
+ return await asyncio.to_thread(fn, **fn_args, mcp_client=mcp_client)
44
+
45
+ if fn_name not in TOOL_MAP:
46
+ raise KeyError(f"Tool '{fn_name}' not found.")
47
+
48
+ fn = TOOL_MAP[fn_name]
49
+ if inspect.iscoroutinefunction(fn):
50
+ return await fn(**fn_args)
51
+ else:
52
+ return await asyncio.to_thread(fn, **fn_args)
dostuff/cli.py ADDED
@@ -0,0 +1,105 @@
1
+ import typer
2
+ import sys
3
+
4
+ app = typer.Typer()
5
+
6
+ @app.callback(invoke_without_command=True)
7
+ def main(
8
+ ctx: typer.Context = typer.Option(None),
9
+ session: str = typer.Option(None, "--session", help="Session ID (default: new UUID)"),
10
+ user: str = typer.Option(None, "--user", help="User ID (default: from ~/.dostuff/user_id)"),
11
+ ):
12
+ if ctx.invoked_subcommand is not None:
13
+ return # subcommand (init/config/doctor/session_list/resume) — don't start agent
14
+ from dostuff.config import Config
15
+
16
+ config = Config()
17
+ user_id = user or config.get_user_id()
18
+ session_id = session or (sys.argv[0] + "_" + __import__("uuid").uuid4().hex[:8])
19
+ if session is None and session_id.startswith(sys.argv[0]):
20
+ session_id = __import__("uuid").uuid4().hex[:16]
21
+ typer.echo(f"USER: {user_id} SESSION: {session_id}")
22
+ typer.echo(f"DATA_DIR: {config.get_data_dir()}")
23
+ typer.echo("Starting TUI...")
24
+ typer.echo("STARTING DoStuff...")
25
+ # Real TUI — default experience (not skeleton)
26
+ from dostuff.cli_tui import DostuffTUI
27
+ tui_app = DostuffTUI(session_id=session_id, user_id=user_id)
28
+ tui_app.run()
29
+
30
+ @app.command()
31
+ def init():
32
+ import pathlib
33
+ cwd = pathlib.Path.cwd()
34
+ proj_dir = cwd / ".dostuff"
35
+ proj_dir.mkdir(exist_ok=True)
36
+
37
+ # Create project config.yaml with template
38
+ config_template = """# Project-specific config (overrides ~/.dostuff/config.yaml)
39
+ # Uncomment any section below to override the global config.
40
+ # Full reference: https://github.com/<you>/dostuff/blob/main/config.example.yaml
41
+
42
+ # data:
43
+ # mode: "project" # use project data dir instead of global
44
+
45
+ # mcp:
46
+ # config_path: "mcp_config.json" # project-specific MCP servers
47
+
48
+ # model:
49
+ # name: "openai/gpt-4o-mini" # litellm format
50
+ # api_key_env: "OPENAI_API_KEY" # env var with key (never put key in YAML)
51
+
52
+ # tracing:
53
+ # enabled: false
54
+ # exporter: "otlp"
55
+ """
56
+ (proj_dir / "config.yaml").write_text(config_template)
57
+
58
+ # Create directories
59
+ (proj_dir / "skills").mkdir(exist_ok=True)
60
+
61
+ typer.echo(f"Initialized project at {cwd}")
62
+
63
+ @app.command()
64
+ def config():
65
+ from dostuff.config import Config
66
+
67
+ cfg = Config()
68
+ typer.echo(f"Global config: {cfg.global_path}")
69
+ # typer.echo(f"Data mode: {cfg.raw.get('data',{}).get('mode','global')}")
70
+ typer.echo(f"Data dir: {cfg.get_data_dir()}")
71
+ typer.echo(f"User ID: {cfg.get_user_id()}")
72
+
73
+ @app.command()
74
+ def doctor():
75
+ typer.echo("Health check: dostuff package installed.")
76
+ from dostuff.config import Config
77
+
78
+ cfg = Config()
79
+ typer.echo(f" Global config: {cfg.global_path} (exists={cfg.global_path.exists()})")
80
+ cwd = __import__('pathlib').Path.cwd()
81
+ proj = cwd / ".dostuff" / "config.yaml"
82
+ typer.echo(f" Project config: {proj} (exists={proj.exists()})")
83
+ typer.echo(f" User ID file: {cfg.user_id_path} (exists={cfg.user_id_path.exists()})")
84
+ typer.echo(f" Data dir: {cfg.get_data_dir()} (exists={cfg.get_data_dir().exists()})")
85
+
86
+ @app.command()
87
+ def session_list():
88
+ import asyncio
89
+ from dostuff.memory import SQLiteSessionStore
90
+ from dostuff.config import Config
91
+
92
+ async def _run():
93
+ cfg = Config()
94
+ store = SQLiteSessionStore(db_path=str(cfg.get_data_dir() / "sessions.db"))
95
+ rows = await store.list()
96
+ if not rows:
97
+ typer.echo("No past sessions.")
98
+ return
99
+ typer.echo(f"{'SESSION':<36} | {'WORKING_DIR':<60}")
100
+ typer.echo("-" * 100)
101
+ for sid, wd in rows:
102
+ typer.echo(f"{sid:<36} | {str(wd):<60}")
103
+ asyncio.run(_run())
104
+
105
+